cdpfleet Docs GitHub Dashboard

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.

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

WhatWhy
context.newCDPSession(page)A raw CDP channel to the page over the normal Playwright connection (Chromium-based browsers) — no cdp: true needed.
Network.emulateNetworkConditionsChrome DevTools' throttling: latency and up/down throughput, or offline.
Fast 4G and Slow 3G presetsThe numbers DevTools uses: 150 ms / 9 Mbit/s, and 2,000 ms / 400 kbit/s.
A fresh context per runNo 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, with the raw output). Set these environment variables first:

// 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();
}

What we got

NetworkHTML ready (ms)Fully loaded (ms)
no throttling27193317
Fast 4G19482230
Slow 3G875215712
Offlinenet::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