[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #36 · 2026-10-11 · Example · Easy · for [testing](https://cdpfleet.com/cross-browser-testing)

# Slow 3G on a remote browser: network throttling over a CDP session

Throttle a remote Chromium to Fast 4G and Slow 3G — and take it offline — with a CDP session over the ordinary Playwright connection, then time how long the same page takes.

[Chromium](https://cdpfleet.com/docs/browsers/chromium)

Run on production on 2026-10-11: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

## The problem

A fleet browser sits on a fast data-centre link behind your proxy, so it never sees what a phone on a weak connection sees: late images, slow first paint, timeouts your code doesn't expect. Playwright has no throttling option of its own, but Chromium does — through the DevTools protocol. Can you reach it on a remote browser without switching to `cdpUrl`?

## What we used, and why

| What | Why |
| --- | --- |
| `context.newCDPSession(page)` | A raw CDP channel to the page over the normal Playwright connection (Chromium-based browsers) — no `cdp: true` needed. |
| `Network.emulateNetworkConditions` | Chrome DevTools' throttling: latency and up/down throughput, or offline. |
| Fast 4G and Slow 3G presets | The numbers DevTools uses: 150 ms / 9 Mbit/s, and 2,000 ms / 400 kbit/s. |
| A fresh context per run | No cache carried over, so every profile loads the whole page. |

## How it works

1.  Launch Chromium and, for each profile, open a context and a CDP session for its page.
2.  Apply the throttling, load books.toscrape.com, and time the HTML and the full load.
3.  Finally switch a page offline and try to load it.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/36-slow-network), with the raw output). Set these environment variables first:

-   `CDPFLEET_API_KEY` — your API key (dashboard → API keys)
-   `PROXY_URL` — your proxy, e.g. `http://user:pass@proxy.example.com:8000`

 

```node
// npm install playwright@1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const URL = 'https://books.toscrape.com/';
// Chrome DevTools' presets (throughput in bytes/s, latency in ms).
const PROFILES = {
  'no throttling': null,
  'Fast 4G': { latency: 150, downloadThroughput: (9 * 1024 * 1024) / 8, uploadThroughput: (1.5 * 1024 * 1024) / 8 },
  'Slow 3G': { latency: 2000, downloadThroughput: (400 * 1024) / 8, uploadThroughput: (400 * 1024) / 8 },
};

const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
  method: 'POST',
  headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
  body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: 'new' }),
});
if (!res.ok) throw new Error(`launch ${res.status} ${await res.text()}`);
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });

try {
  const rows = [];
  for (const [profile, conditions] of Object.entries(PROFILES)) {
    const context = await browser.newContext(); // no cache between runs
    const page = await context.newPage();
    // A CDP session works over the normal Playwright connection on Chromium-based browsers.
    const cdp = await context.newCDPSession(page);
    await cdp.send('Network.enable');
    if (conditions) await cdp.send('Network.emulateNetworkConditions', { offline: false, ...conditions });
    const t = Date.now();
    await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
    const htmlMs = Date.now() - t;
    await page.waitForLoadState('load', { timeout: 180000 });
    rows.push({ profile, html_ms: htmlMs, load_ms: Date.now() - t });
    await context.close();
  }
  // Offline: the page fails like it would on a dead connection.
  const context = await browser.newContext();
  const page = await context.newPage();
  const cdp = await context.newCDPSession(page);
  await cdp.send('Network.enable');
  await cdp.send('Network.emulateNetworkConditions', { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
  const offline = await page.goto(URL, { timeout: 30000 }).then(() => 'loaded', (e) => (e.message.match(/net::[A-Z_]+/) || [e.message.split('\n')[0]])[0]);
  await context.close();
  console.log(JSON.stringify({ rows, offline }, null, 2));
} finally {
  await browser.close();
}
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import re
import time

import requests
from playwright.sync_api import Error, sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]
URL = "https://books.toscrape.com/"
# Chrome DevTools' presets (throughput in bytes/s, latency in ms).
PROFILES = {
    "no throttling": None,
    "Fast 4G": {"latency": 150, "downloadThroughput": 9 * 1024 * 1024 / 8, "uploadThroughput": 1.5 * 1024 * 1024 / 8},
    "Slow 3G": {"latency": 2000, "downloadThroughput": 400 * 1024 / 8, "uploadThroughput": 400 * 1024 / 8},
}

def ms_since(t):
    return round((time.time() - t) * 1000)

res = requests.post("https://starter.cdpfleet.com/chromium/session",
    headers={"x-api-key": KEY},
    json={"proxy": os.environ["PROXY_URL"], "headless": "new"}, timeout=60)
if not res.ok:
    raise SystemExit(f"launch {res.status_code} {res.text}")

with sync_playwright() as p:
    browser = p.chromium.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
    try:
        rows = []
        for profile, conditions in PROFILES.items():
            context = browser.new_context()  # no cache between runs
            page = context.new_page()
            # A CDP session works over the normal Playwright connection on Chromium-based browsers.
            cdp = context.new_cdp_session(page)
            cdp.send("Network.enable")
            if conditions:
                cdp.send("Network.emulateNetworkConditions", {"offline": False, **conditions})
            t = time.time()
            page.goto(URL, wait_until="domcontentloaded", timeout=120000)
            html_ms = ms_since(t)
            page.wait_for_load_state("load", timeout=180000)
            rows.append({"profile": profile, "html_ms": html_ms, "load_ms": ms_since(t)})
            context.close()

        # Offline: the page fails like it would on a dead connection.
        context = browser.new_context()
        page = context.new_page()
        cdp = context.new_cdp_session(page)
        cdp.send("Network.enable")
        cdp.send("Network.emulateNetworkConditions", {"offline": True, "latency": 0, "downloadThroughput": -1, "uploadThroughput": -1})
        try:
            page.goto(URL, timeout=30000)
            offline = "loaded"
        except Error as e:
            m = re.search(r"net::[A-Z_]+", e.message)
            offline = m.group(0) if m else e.message.split("\n")[0]
        context.close()

        print(json.dumps({"rows": rows, "offline": offline}, indent=2))
    finally:
        browser.close()
```

```java
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.LoadState;
import com.microsoft.playwright.options.WaitUntilState;
import java.net.URI;
import java.net.http.*;
import java.util.*;
import java.util.regex.*;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final String URL = "https://books.toscrape.com/";
  static final Gson GSON = new GsonBuilder().serializeNulls().create();

  // Chrome DevTools' presets (throughput in bytes/s, latency in ms).
  static JsonObject conditions(double latency, double download, double upload) {
    JsonObject c = new JsonObject();
    c.addProperty("offline", false);
    c.addProperty("latency", latency);
    c.addProperty("downloadThroughput", download);
    c.addProperty("uploadThroughput", upload);
    return c;
  }

  public static void main(String[] args) throws Exception {
    Map<String, JsonObject> profiles = new LinkedHashMap<>();
    profiles.put("no throttling", null);
    profiles.put("Fast 4G", conditions(150, 9 * 1024 * 1024 / 8.0, 1.5 * 1024 * 1024 / 8));
    profiles.put("Slow 3G", conditions(2000, 400 * 1024 / 8.0, 400 * 1024 / 8.0));

    String body = "{\"proxy\": " + GSON.toJson(System.getenv("PROXY_URL")) + ", \"headless\": \"new\"}";
    HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chromium/session"))
        .header("x-api-key", KEY).header("content-type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
    if (res.statusCode() != 200) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
    String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();

    try (Playwright playwright = Playwright.create()) {
      Browser browser = playwright.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
      try {
        JsonArray rows = new JsonArray();
        for (Map.Entry<String, JsonObject> profile : profiles.entrySet()) {
          BrowserContext context = browser.newContext(); // no cache between runs
          Page page = context.newPage();
          // A CDP session works over the normal Playwright connection on Chromium-based browsers.
          CDPSession cdp = context.newCDPSession(page);
          cdp.send("Network.enable");
          if (profile.getValue() != null) cdp.send("Network.emulateNetworkConditions", profile.getValue());
          long t = System.currentTimeMillis();
          page.navigate(URL, new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(120000));
          long htmlMs = System.currentTimeMillis() - t;
          page.waitForLoadState(LoadState.LOAD, new Page.WaitForLoadStateOptions().setTimeout(180000));
          JsonObject row = new JsonObject();
          row.addProperty("profile", profile.getKey());
          row.addProperty("html_ms", htmlMs);
          row.addProperty("load_ms", System.currentTimeMillis() - t);
          rows.add(row);
          context.close();
        }

        // Offline: the page fails like it would on a dead connection.
        BrowserContext context = browser.newContext();
        Page page = context.newPage();
        CDPSession cdp = context.newCDPSession(page);
        cdp.send("Network.enable");
        JsonObject off = new JsonObject();
        off.addProperty("offline", true);
        off.addProperty("latency", 0);
        off.addProperty("downloadThroughput", -1);
        off.addProperty("uploadThroughput", -1);
        cdp.send("Network.emulateNetworkConditions", off);
        String offline;
        try {
          page.navigate(URL, new Page.NavigateOptions().setTimeout(30000));
          offline = "loaded";
        } catch (PlaywrightException e) {
          Matcher m = Pattern.compile("net::[A-Z_]+").matcher(e.getMessage());
          offline = m.find() ? m.group() : e.getMessage().split("\n")[0];
        }
        context.close();

        JsonObject out = new JsonObject();
        out.add("rows", rows);
        out.addProperty("offline", offline);
        System.out.println(new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create().toJson(out));
      } finally {
        browser.close();
      }
    }
  }
}
```

```csharp
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;

const string Url = "https://books.toscrape.com/";
// Chrome DevTools' presets (throughput in bytes/s, latency in ms).
var profiles = new (string Name, Dictionary<string, object>? Conditions)[]
{
    ("no throttling", null),
    ("Fast 4G", new() { ["offline"] = false, ["latency"] = 150, ["downloadThroughput"] = 9 * 1024 * 1024 / 8.0, ["uploadThroughput"] = 1.5 * 1024 * 1024 / 8 }),
    ("Slow 3G", new() { ["offline"] = false, ["latency"] = 2000, ["downloadThroughput"] = 400 * 1024 / 8.0, ["uploadThroughput"] = 400 * 1024 / 8.0 }),
};

var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session",
    new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = "new" });
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;

using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
    var rows = new JsonArray();
    foreach (var (name, conditions) in profiles)
    {
        var context = await browser.NewContextAsync(); // no cache between runs
        var page = await context.NewPageAsync();
        // A CDP session works over the normal Playwright connection on Chromium-based browsers.
        var cdp = await context.NewCDPSessionAsync(page);
        await cdp.SendAsync("Network.enable");
        if (conditions != null) await cdp.SendAsync("Network.emulateNetworkConditions", conditions);
        var sw = System.Diagnostics.Stopwatch.StartNew();
        await page.GotoAsync(Url, new() { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 120000 });
        var htmlMs = sw.ElapsedMilliseconds;
        await page.WaitForLoadStateAsync(LoadState.Load, new() { Timeout = 180000 });
        rows.Add(new JsonObject { ["profile"] = name, ["html_ms"] = htmlMs, ["load_ms"] = sw.ElapsedMilliseconds });
        await context.CloseAsync();
    }

    // Offline: the page fails like it would on a dead connection.
    var offContext = await browser.NewContextAsync();
    var offPage = await offContext.NewPageAsync();
    var offCdp = await offContext.NewCDPSessionAsync(offPage);
    await offCdp.SendAsync("Network.enable");
    await offCdp.SendAsync("Network.emulateNetworkConditions", new() { ["offline"] = true, ["latency"] = 0, ["downloadThroughput"] = -1, ["uploadThroughput"] = -1 });
    string offline;
    try
    {
        await offPage.GotoAsync(Url, new() { Timeout = 30000 });
        offline = "loaded";
    }
    catch (PlaywrightException e)
    {
        var m = Regex.Match(e.Message, "net::[A-Z_]+");
        offline = m.Success ? m.Value : e.Message.Split('\n')[0];
    }
    await offContext.CloseAsync();

    var result = new JsonObject { ["rows"] = rows, ["offline"] = offline };
    Console.WriteLine(result.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
}
finally
{
    await browser.CloseAsync();
}
```

```go
// go get github.com/playwright-community/playwright-go@v0.6000.0
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"regexp"
	"strings"
	"time"

	"github.com/playwright-community/playwright-go"
)

var key = os.Getenv("CDPFLEET_API_KEY")

const url = "https://books.toscrape.com/"

// Chrome DevTools' presets (throughput in bytes/s, latency in ms).
var profiles = []struct {
	name       string
	conditions map[string]any
}{
	{"no throttling", nil},
	{"Fast 4G", map[string]any{"offline": false, "latency": 150, "downloadThroughput": 9 * 1024 * 1024 / 8.0, "uploadThroughput": 1.5 * 1024 * 1024 / 8}},
	{"Slow 3G", map[string]any{"offline": false, "latency": 2000, "downloadThroughput": 400 * 1024 / 8.0, "uploadThroughput": 400 * 1024 / 8.0}},
}

func launch(name string, options map[string]any) (map[string]any, error) {
	body, _ := json.Marshal(options)
	req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+name+"/session", bytes.NewReader(body))
	req.Header.Set("x-api-key", key)
	req.Header.Set("content-type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		msg, _ := io.ReadAll(res.Body)
		return nil, fmt.Errorf("launch %s: %s %s", name, res.Status, msg)
	}
	var session map[string]any
	return session, json.NewDecoder(res.Body).Decode(&session)
}

type row struct {
	Profile string `json:"profile"`
	HTMLMs  int64  `json:"html_ms"`
	LoadMs  int64  `json:"load_ms"`
}

type output struct {
	Rows    []row  `json:"rows"`
	Offline string `json:"offline"`
}

// newPage opens a fresh context (no cache between runs) with a CDP session on its page.
// A CDP session works over the normal Playwright connection on Chromium-based browsers.
func newPage(browser playwright.Browser) (playwright.BrowserContext, playwright.Page, playwright.CDPSession) {
	context, err := browser.NewContext()
	if err != nil {
		log.Fatal(err)
	}
	page, err := context.NewPage()
	if err != nil {
		log.Fatal(err)
	}
	cdp, err := context.NewCDPSession(page)
	if err != nil {
		log.Fatal(err)
	}
	if _, err := cdp.Send("Network.enable", nil); err != nil {
		log.Fatal(err)
	}
	return context, page, cdp
}

func run() (output, error) {
	session, err := launch("chromium", map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": "new"})
	if err != nil {
		return output{}, err
	}
	pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
	if err != nil {
		return output{}, err
	}
	defer pw.Stop()
	browser, err := pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
	if err != nil {
		return output{}, err
	}
	defer browser.Close()

	out := output{Rows: []row{}}
	for _, p := range profiles {
		context, page, cdp := newPage(browser)
		if p.conditions != nil {
			if _, err := cdp.Send("Network.emulateNetworkConditions", p.conditions); err != nil {
				return out, err
			}
		}
		t := time.Now()
		if _, err := page.Goto(url, playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateDomcontentloaded, Timeout: playwright.Float(120000)}); err != nil {
			return out, err
		}
		htmlMs := time.Since(t).Milliseconds()
		if err := page.WaitForLoadState(playwright.PageWaitForLoadStateOptions{State: playwright.LoadStateLoad, Timeout: playwright.Float(180000)}); err != nil {
			return out, err
		}
		out.Rows = append(out.Rows, row{p.name, htmlMs, time.Since(t).Milliseconds()})
		context.Close()
	}

	// Offline: the page fails like it would on a dead connection.
	context, page, cdp := newPage(browser)
	if _, err := cdp.Send("Network.emulateNetworkConditions", map[string]any{"offline": true, "latency": 0, "downloadThroughput": -1, "uploadThroughput": -1}); err != nil {
		return out, err
	}
	if _, err := page.Goto(url, playwright.PageGotoOptions{Timeout: playwright.Float(30000)}); err != nil {
		if m := regexp.MustCompile(`net::[A-Z_]+`).FindString(err.Error()); m != "" {
			out.Offline = m
		} else {
			out.Offline = strings.SplitN(err.Error(), "\n", 2)[0]
		}
	} else {
		out.Offline = "loaded"
	}
	context.Close()
	return out, nil
}

func main() {
	out, err := run()
	if err != nil {
		log.Fatal(err)
	}
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	enc.SetIndent("", "  ")
	enc.Encode(out)
	fmt.Print(buf.String())
}
```

## What we got

| Network | HTML ready (ms) | Fully loaded (ms) |
| --- | --- | --- |
| no throttling | 2719 | 3317 |
| Fast 4G | 1948 | 2230 |
| Slow 3G | 8752 | 15712 |
| Offline | net::ERR\_INTERNET\_DISCONNECTED |  |

From the Node.js run on 2026-10-11. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)

```
{
  "rows": [
    {
      "profile": "no throttling",
      "html_ms": 2719,
      "load_ms": 3317
    },
    {
      "profile": "Fast 4G",
      "html_ms": 1948,
      "load_ms": 2230
    },
    {
      "profile": "Slow 3G",
      "html_ms": 8752,
      "load_ms": 15712
    }
  ],
  "offline": "net::ERR_INTERNET_DISCONNECTED"
}
```

## Takeaways

-   **CDP works over the Playwright connection:** `newCDPSession` gives you DevTools-only features like throttling without launching with `cdp: true`.
-   **Slow 3G turns seconds into many seconds:** the full load went from 2.6–6 s unthrottled to 14.5–16 s on Slow 3G across our five runs — set timeouts for the networks your users have, not for the fleet's. (Fast 4G was within the proxy's own jitter.)
-   **Offline fails the way a dead connection does** (`net::ERR_INTERNET_DISCONNECTED`), so you can test your error handling without breaking the proxy.
-   **Throttling is per page and Chromium-only:** Firefox, Camoufox and WebKit have no CDP session; slow those down with a throttling proxy instead.

---
Source: https://cdpfleet.com/docs/cases/slow-network · Markdown: https://cdpfleet.com/docs/cases/slow-network.md · All docs for AI tools: https://cdpfleet.com/llms.txt
