cdpfleet Docs Sign up free

Examples & benchmarks / #76 · 2026-10-29 · Benchmark · Easy · for web scraping

The SDK vs plain Playwright: the same scrape, and what happens at the thread cap

One small scrape written three ways in each language: a launch request of your own plus Playwright, the cdpfleet SDK, and the SDK's connect URL. The scrape is the same either way; the difference shows when the account is out of threads, where the SDK waits for a free one and the plain request gets an immediate 429.

Chromium

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

A benchmark: the results below are what this setup measured on the dates shown, on our own test runs. Sites, networks and browsers change, so rerun the code before you rely on the numbers.

The problem

cdpfleet works with plain Playwright: one HTTP request launches a browser, connect() with the key header drives it. The SDKs for Node, Python and Go wrap that. Is there a reason to add a dependency for a two-step API? This case runs the same scrape both ways, measures it, and then fills a Community account's two threads to see what each does when a launch is refused.

What we used, and why

WhatWhy
Plain launchPOST https://starter.cdpfleet.com/chromium/session with x-api-key, then Playwright's connect(wsUrl) with the same header; browser.close() ends the session.
SDK launch() + connect()Cdpfleet().launch('chromium', options) returns a session; session.connect(chromium) adds the key header; session.close() cleans up. On 429 (except daily or hourly quota) and 503 it waits Retry-After and tries again, for up to 120 s by default.
SDK connectUrl()A launch-on-connect URL: one Playwright connect() launches the browser and connects to it, no launch request in your code.
A Community accountTwo threads, so holding two launched sessions reaches the cap. Community launches are limited to 2 a second and 12 a minute, so the program spaces them by 1.5 s.
DELETE /v1/me/threads/:idFrees a thread 5 s into the SDK's wait, and ends every session at the end.
C# and JavaNo published SDK yet (Node, Python and Go are on npm, PyPI and the Go proxy), so these programs show the plain version and write the retry loop by hand.

How it works

  1. Scrape three catalogue pages of books.toscrape.com (60 titles) with a plain launch request and Playwright; record when the browser was connected and the total time.
  2. Do the same with the SDK's launch() and connect(), then with its connect URL.
  3. On the Community account, launch two sessions and keep them, then send a third launch request and record the answer.
  4. Launch again with the SDK (or the hand-written loop) while a timer frees one thread after 5 s; record how long the launch took to return a session.
  5. End every session and check that no thread is left running.

The code

The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:

// npm install [email protected] [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL, COMMUNITY_API_KEY (a Community account: 2 threads, to reach the cap)
import { chromium } from 'playwright';
import { Cdpfleet } from 'cdpfleet';

const KEY = process.env.CDPFLEET_API_KEY;
const FREE_KEY = process.env.COMMUNITY_API_KEY;
const PROXY = process.env.PROXY_URL;
const STARTER = 'https://starter.cdpfleet.com';
const API = 'https://cdpfleet.com';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const URLS = [1, 2, 3].map((n) => `https://books.toscrape.com/catalogue/page-${n}.html`);

// The scrape every variant runs: three catalogue pages, every book title.
async function scrape(browser) {
  const page = await browser.newPage();
  const titles = [];
  for (const url of URLS) {
    // A shared proxy drops the odd connection: one retry.
    await page.goto(url, { timeout: 45000 }).catch(() => page.goto(url, { timeout: 45000 }));
    titles.push(...await page.$$eval('article.product_pod h3 a', (as) => as.map((a) => a.getAttribute('title'))));
  }
  return { books: titles.length, first_title: titles[0] };
}

// ---- 1a. Plain Playwright: launch over HTTP yourself, then connect with the key header.
async function plainLaunch(key, body) {
  const res = await fetch(`${STARTER}/chromium/session`, {
    method: 'POST',
    headers: { 'x-api-key': key, 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
  return { status: res.status, retryAfter: res.headers.get('retry-after'), body: await res.json().catch(() => null) };
}
async function plain() {
  const t = Date.now();
  const r = await plainLaunch(KEY, { proxy: PROXY, headless: true, record: false });
  if (r.status !== 200) throw new Error(`launch ${r.status} ${JSON.stringify(r.body)}`);
  const browser = await chromium.connect(r.body.wsUrl, { headers: { 'x-api-key': KEY } });
  const connectedMs = Date.now() - t;
  try {
    return { ...(await scrape(browser)), connected_ms: connectedMs, seconds: (Date.now() - t) / 1000 };
  } finally {
    await browser.close(); // closing the connection ends the session
  }
}

// ---- 1b. The SDK: launch() (retries 429/503 as the API asks), connect(), close().
const fleet = new Cdpfleet(); // reads CDPFLEET_API_KEY
async function withSdk() {
  const t = Date.now();
  const session = await fleet.launch('chromium', { proxy: PROXY, headless: true, record: false });
  const browser = await session.connect(chromium);
  const connectedMs = Date.now() - t;
  try {
    return { ...(await scrape(browser)), connected_ms: connectedMs, seconds: (Date.now() - t) / 1000 };
  } finally {
    await session.close();
  }
}

// ---- 1c. The SDK's connect URL: one connect() launches the browser (no HTTP call of your own).
async function withConnectUrl() {
  const t = Date.now();
  const url = fleet.connectUrl('chromium', { proxy: PROXY, headless: true, record: false });
  const browser = await chromium.connect(url, { headers: fleet.headers() });
  const connectedMs = Date.now() - t;
  try {
    return { ...(await scrape(browser)), connected_ms: connectedMs, seconds: (Date.now() - t) / 1000 };
  } finally {
    await browser.close();
  }
}

// ---- 2. At the thread cap (Community: 2 threads). Hold both, then ask for a third.
const endSession = (key, id) => fetch(`${API}/v1/me/threads/${id}`, { method: 'DELETE', headers: { 'x-api-key': key } });
async function liveThreads(key) {
  const r = await fetch(`${API}/v1/me/threads`, { headers: { 'x-api-key': key } });
  return (await r.json()).threads.length;
}
async function atTheCap() {
  const held = [];
  for (let i = 0; i < 2; i++) {
    const r = await plainLaunch(FREE_KEY, { headless: true, record: false });
    if (r.status !== 200) throw new Error(`hold ${r.status} ${JSON.stringify(r.body)}`);
    held.push(r.body.sessionId);
    await sleep(1500); // Community launches: at most 2 a second, 12 a minute
  }
  // Plain: the answer comes back at once and the retry is yours to write.
  const third = await plainLaunch(FREE_KEY, { headless: true, record: false });
  await sleep(1500);
  // SDK: the same launch waits; free a thread 5 s later and see when it returns.
  const freeFleet = new Cdpfleet({ apiKey: FREE_KEY });
  setTimeout(() => endSession(FREE_KEY, held.shift()), 5000);
  const t = Date.now();
  let retried;
  try {
    const s = await freeFleet.launch('chromium', { headless: true, record: false });
    retried = { by: 'sdk', got_session: true, waited_s: (Date.now() - t) / 1000 };
    await s.end(); // never connected: end it explicitly
  } catch (err) {
    retried = { by: 'sdk', got_session: false, waited_s: (Date.now() - t) / 1000, error: err.code };
  }
  for (const id of held) await endSession(FREE_KEY, id);
  await sleep(3000);
  return {
    threads: 2,
    plain: { status: third.status, error: third.body?.error, retry_after: third.retryAfter, in_use: third.body?.in_use, upgrade_url: third.body?.upgrade?.url ?? null },
    retry: retried,
    threads_left: await liveThreads(FREE_KEY),
  };
}

const out = { language: 'node', sdk: 'cdpfleet 0.1.1 (npm)', scrape: {} };
out.scrape.plain = await plain();
out.scrape.sdk = await withSdk();
out.scrape.connect_url = await withConnectUrl();
out.cap = await atTheCap();
out.measured_at = new Date().toISOString().slice(0, 10);
console.log(JSON.stringify(out, null, 2));

What we got

LanguagePlain PlaywrightSDK launch() + connect()Connect URLThird launch at the capLaunch that waitsMeasured
node60 books · connected in 579 ms · 8.4 s60 books · connected in 370 ms · 11.2 s60 books · connected in 328 ms · 6.8 s429 threads_exceeded, Retry-After 1 sSDK: session after 6.5 s2026-10-29

From the Node.js run on 2026-10-29. 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)
{
  "language": "node",
  "sdk": "cdpfleet 0.1.1 (npm)",
  "scrape": {
    "plain": {
      "books": 60,
      "first_title": "A Light in the Attic",
      "connected_ms": 579,
      "seconds": 8.412
    },
    "sdk": {
      "books": 60,
      "first_title": "A Light in the Attic",
      "connected_ms": 370,
      "seconds": 11.204
    },
    "connect_url": {
      "books": 60,
      "first_title": "A Light in the Attic",
      "connected_ms": 328,
      "seconds": 6.834
    }
  },
  "cap": {
    "threads": 2,
    "plain": {
      "status": 429,
      "error": "threads_exceeded",
      "retry_after": "1",
      "in_use": 2,
      "upgrade_url": "https://cdpfleet.com/upgrade"
    },
    "retry": {
      "by": "sdk",
      "got_session": true,
      "waited_s": 6.532
    },
    "threads_left": 0
  },
  "measured_at": "2026-10-29"
}

Takeaways