Cases / #36 · 2026-10-11 · Easy
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.
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
- Launch Chromium and, for each profile, open a context and a CDP session for its page.
- Apply the throttling, load books.toscrape.com, and time the HTML and the full load.
- Finally switch a page offline and try to load it.
The code
The same program in five languages (also on GitHub, 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:[email protected]:8000
// npm install [email protected]
// 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();
}
# 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()
// 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();
}
}
}
}
// 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 get github.com/playwright-community/[email protected]
// 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:
newCDPSessiongives you DevTools-only features like throttling without launching withcdp: 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.