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.
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
| What | Why |
|---|---|
| Plain launch | POST 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 account | Two 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/:id | Frees a thread 5 s into the SDK's wait, and ends every session at the end. |
| C# and Java | No 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
- 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.
- Do the same with the SDK's
launch()andconnect(), then with its connect URL. - On the Community account, launch two sessions and keep them, then send a third launch request and record the answer.
- 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.
- 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:
CDPFLEET_API_KEY— your API key (dashboard → API keys)COMMUNITY_API_KEY— an API key of an account on the free Community plan (dashboard → API keys); its launches use the plan's included proxyPROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// 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));
# pip install playwright==1.60.0 requests cdpfleet==0.1.1
# env: CDPFLEET_API_KEY, PROXY_URL, COMMUNITY_API_KEY (a Community account: 2 threads, to reach the cap)
import json
import os
import threading
import time
from datetime import date
import requests
from cdpfleet import Cdpfleet, CdpfleetError
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
FREE_KEY = os.environ["COMMUNITY_API_KEY"]
PROXY = os.environ["PROXY_URL"]
STARTER = "https://starter.cdpfleet.com"
API = "https://cdpfleet.com"
URLS = [f"https://books.toscrape.com/catalogue/page-{n}.html" for n in (1, 2, 3)]
# The scrape every variant runs: three catalogue pages, every book title.
def scrape(browser):
page = browser.new_page()
titles = []
for url in URLS:
try:
page.goto(url, timeout=45000)
except Exception: # a shared proxy drops the odd connection: one retry
page.goto(url, timeout=45000)
titles += page.eval_on_selector_all("article.product_pod h3 a", "as => as.map(a => a.getAttribute('title'))")
return {"books": len(titles), "first_title": titles[0]}
# ---- 1a. Plain Playwright: launch over HTTP yourself, then connect with the key header.
def plain_launch(key, body):
res = requests.post(f"{STARTER}/chromium/session", headers={"x-api-key": key}, json=body, timeout=60)
try:
data = res.json()
except ValueError:
data = None
return res.status_code, res.headers.get("retry-after"), data
def plain(p):
t = time.time()
status, _, body = plain_launch(KEY, {"proxy": PROXY, "headless": True, "record": False})
if status != 200:
raise RuntimeError(f"launch {status} {body}")
browser = p.chromium.connect(body["wsUrl"], headers={"x-api-key": KEY})
connected_ms = round((time.time() - t) * 1000)
try:
return {**scrape(browser), "connected_ms": connected_ms, "seconds": round(time.time() - t, 3)}
finally:
browser.close() # closing the connection ends the session
# ---- 1b. The SDK: launch() (retries 429/503 as the API asks), connect(), close() via `with`.
fleet = Cdpfleet() # reads CDPFLEET_API_KEY
def with_sdk(p):
t = time.time()
with fleet.launch("chromium", proxy=PROXY, headless=True, record=False) as session:
browser = session.connect(p.chromium)
connected_ms = round((time.time() - t) * 1000)
return {**scrape(browser), "connected_ms": connected_ms, "seconds": round(time.time() - t, 3)}
# ---- 1c. The SDK's connect URL: one connect() launches the browser (no HTTP call of your own).
def with_connect_url(p):
t = time.time()
url = fleet.connect_url("chromium", proxy=PROXY, headless=True, record=False)
browser = p.chromium.connect(url, headers={"x-api-key": fleet.api_key})
connected_ms = round((time.time() - t) * 1000)
try:
return {**scrape(browser), "connected_ms": connected_ms, "seconds": round(time.time() - t, 3)}
finally:
browser.close()
# ---- 2. At the thread cap (Community: 2 threads). Hold both, then ask for a third.
def end_session(key, sid):
requests.delete(f"{API}/v1/me/threads/{sid}", headers={"x-api-key": key}, timeout=30)
def live_threads(key):
return len(requests.get(f"{API}/v1/me/threads", headers={"x-api-key": key}, timeout=30).json()["threads"])
def at_the_cap():
held = []
for _ in range(2):
status, _, body = plain_launch(FREE_KEY, {"headless": True, "record": False})
if status != 200:
raise RuntimeError(f"hold {status} {body}")
held.append(body["sessionId"])
time.sleep(1.5) # Community launches: at most 2 a second, 12 a minute
# Plain: the answer comes back at once and the retry is yours to write.
status, retry_after, body = plain_launch(FREE_KEY, {"headless": True, "record": False})
time.sleep(1.5)
# SDK: the same launch waits; free a thread 5 s later and see when it returns.
free_fleet = Cdpfleet(api_key=FREE_KEY)
threading.Timer(5, end_session, (FREE_KEY, held.pop(0))).start()
t = time.time()
try:
s = free_fleet.launch("chromium", headless=True, record=False)
retry = {"by": "sdk", "got_session": True, "waited_s": round(time.time() - t, 3)}
s.end() # never connected: end it explicitly
except CdpfleetError as err:
retry = {"by": "sdk", "got_session": False, "waited_s": round(time.time() - t, 3), "error": err.code}
for sid in held:
end_session(FREE_KEY, sid)
time.sleep(3)
return {
"threads": 2,
"plain": {"status": status, "error": (body or {}).get("error"), "retry_after": retry_after,
"in_use": (body or {}).get("in_use"), "upgrade_url": ((body or {}).get("upgrade") or {}).get("url")},
"retry": retry,
"threads_left": live_threads(FREE_KEY),
}
with sync_playwright() as p:
out = {"language": "python", "sdk": "cdpfleet 0.1.1 (PyPI)", "scrape": {}}
out["scrape"]["plain"] = plain(p)
out["scrape"]["sdk"] = with_sdk(p)
out["scrape"]["connect_url"] = with_connect_url(p)
out["cap"] = at_the_cap()
out["measured_at"] = date.today().isoformat()
print(json.dumps(out, indent=2))
// 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, COMMUNITY_API_KEY (a Community account: 2 threads, to reach the cap)
// There is no published cdpfleet SDK for Java yet: this is the plain version, with the retry written by hand.
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String FREE_KEY = System.getenv("COMMUNITY_API_KEY");
static final String PROXY = System.getenv("PROXY_URL");
static final String STARTER = "https://starter.cdpfleet.com";
static final String API = "https://cdpfleet.com";
static final HttpClient HTTP = HttpClient.newHttpClient();
static final Gson GSON = new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create();
static final String[] URLS = {
"https://books.toscrape.com/catalogue/page-1.html",
"https://books.toscrape.com/catalogue/page-2.html",
"https://books.toscrape.com/catalogue/page-3.html",
};
// The scrape every variant runs: three catalogue pages, every book title.
static JsonObject scrape(Browser browser, long start, long connectedMs) {
Page page = browser.newPage();
List<String> titles = new ArrayList<>();
for (String url : URLS) {
// A shared proxy drops the odd connection: one retry.
try { page.navigate(url, new Page.NavigateOptions().setTimeout(45000)); }
catch (PlaywrightException e) { page.navigate(url, new Page.NavigateOptions().setTimeout(45000)); }
for (ElementHandle a : page.querySelectorAll("article.product_pod h3 a")) titles.add(a.getAttribute("title"));
}
JsonObject r = new JsonObject();
r.addProperty("books", titles.size());
r.addProperty("first_title", titles.get(0));
r.addProperty("connected_ms", connectedMs);
r.addProperty("seconds", (System.currentTimeMillis() - start) / 1000.0);
return r;
}
// ---- 1a. Plain Playwright: launch over HTTP yourself, then connect with the key header.
static HttpResponse<String> launch(String key, String body) throws Exception {
return HTTP.send(HttpRequest.newBuilder(URI.create(STARTER + "/chromium/session"))
.header("x-api-key", key).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
}
static JsonObject plain(Playwright pw) throws Exception {
long t = System.currentTimeMillis();
HttpResponse<String> res = launch(KEY, "{\"proxy\": " + GSON.toJson(PROXY) + ", \"headless\": true, \"record\": false}");
if (res.statusCode() != 200) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
Browser browser = pw.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try { return scrape(browser, t, System.currentTimeMillis() - t); }
finally { browser.close(); } // closing the connection ends the session
}
// ---- 1c. A connect URL built by hand: one connect() launches the browser.
static JsonObject connectUrl(Playwright pw) {
long t = System.currentTimeMillis();
String url = "wss://router01.cdpfleet.com/connect/chromium?proxy=" + URLEncoder.encode(PROXY, StandardCharsets.UTF_8) + "&headless=true&record=false";
Browser browser = pw.chromium().connect(url, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try { return scrape(browser, t, System.currentTimeMillis() - t); }
finally { browser.close(); }
}
// ---- 2. At the thread cap (Community: 2 threads). Hold both, then ask for a third.
static void endSession(String key, String id) {
try {
HTTP.send(HttpRequest.newBuilder(URI.create(API + "/v1/me/threads/" + id)).header("x-api-key", key).DELETE().build(), HttpResponse.BodyHandlers.discarding());
} catch (Exception ignored) { }
}
static int liveThreads(String key) throws Exception {
HttpResponse<String> r = HTTP.send(HttpRequest.newBuilder(URI.create(API + "/v1/me/threads")).header("x-api-key", key).build(), HttpResponse.BodyHandlers.ofString());
return JsonParser.parseString(r.body()).getAsJsonObject().getAsJsonArray("threads").size();
}
// What the SDKs do for you: retry 429 (not the daily/hourly quota) and 503, waiting Retry-After, up to 120 s.
static JsonObject launchWithRetry(String key, String body) throws Exception {
long deadline = System.currentTimeMillis() + 120_000;
for (int attempt = 1; ; attempt++) {
HttpResponse<String> res = launch(key, body);
if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject();
String code = "";
try { code = JsonParser.parseString(res.body()).getAsJsonObject().get("error").getAsString(); } catch (Exception ignored) { }
boolean quota = code.equals("daily_quota_exhausted") || code.equals("hourly_quota_exhausted");
boolean retryable = res.statusCode() == 503 || (res.statusCode() == 429 && !quota);
if (!retryable || System.currentTimeMillis() > deadline) throw new RuntimeException("launch " + res.statusCode() + " " + code);
double after = res.headers().firstValue("retry-after").map(Double::parseDouble).orElse(Math.min(Math.pow(2, attempt - 1), 10));
Thread.sleep((long) (after * 1000 + Math.random() * 500));
}
}
static JsonObject atTheCap() throws Exception {
String body = "{\"headless\": true, \"record\": false}";
List<String> held = new ArrayList<>();
for (int i = 0; i < 2; i++) {
HttpResponse<String> r = launch(FREE_KEY, body);
if (r.statusCode() != 200) throw new RuntimeException("hold " + r.statusCode() + " " + r.body());
held.add(JsonParser.parseString(r.body()).getAsJsonObject().get("sessionId").getAsString());
Thread.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.
HttpResponse<String> third = launch(FREE_KEY, body);
JsonObject tb = JsonParser.parseString(third.body()).getAsJsonObject();
Thread.sleep(1500);
// The hand-written loop: free a thread 5 s later and see when it returns.
String first = held.remove(0);
ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor();
timer.schedule(() -> endSession(FREE_KEY, first), 5, TimeUnit.SECONDS);
long t = System.currentTimeMillis();
JsonObject retry = new JsonObject();
retry.addProperty("by", "hand-written");
try {
JsonObject s = launchWithRetry(FREE_KEY, body);
retry.addProperty("got_session", true);
retry.addProperty("waited_s", (System.currentTimeMillis() - t) / 1000.0);
endSession(FREE_KEY, s.get("sessionId").getAsString()); // never connected: end it explicitly
} catch (RuntimeException e) {
retry.addProperty("got_session", false);
retry.addProperty("waited_s", (System.currentTimeMillis() - t) / 1000.0);
retry.addProperty("error", e.getMessage());
}
timer.shutdown();
for (String id : held) endSession(FREE_KEY, id);
Thread.sleep(3000);
JsonObject plain = new JsonObject();
plain.addProperty("status", third.statusCode());
plain.add("error", tb.get("error"));
plain.addProperty("retry_after", third.headers().firstValue("retry-after").orElse(null));
plain.add("in_use", tb.get("in_use"));
plain.add("upgrade_url", tb.has("upgrade") ? tb.getAsJsonObject("upgrade").get("url") : JsonNull.INSTANCE);
JsonObject cap = new JsonObject();
cap.addProperty("threads", 2);
cap.add("plain", plain);
cap.add("retry", retry);
cap.addProperty("threads_left", liveThreads(FREE_KEY));
return cap;
}
public static void main(String[] args) throws Exception {
try (Playwright pw = Playwright.create()) {
JsonObject out = new JsonObject();
out.addProperty("language", "java");
out.add("sdk", JsonNull.INSTANCE);
JsonObject scrape = new JsonObject();
scrape.add("plain", plain(pw));
scrape.add("sdk", JsonNull.INSTANCE);
scrape.add("connect_url", connectUrl(pw));
out.add("scrape", scrape);
out.add("cap", atTheCap());
out.addProperty("measured_at", LocalDate.now().toString());
System.out.println(GSON.toJson(out));
}
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL, COMMUNITY_API_KEY (a Community account: 2 threads, to reach the cap)
// There is no published cdpfleet SDK for .NET yet: this is the plain version, with the retry written by hand.
using System.Diagnostics;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var freeKey = Environment.GetEnvironmentVariable("COMMUNITY_API_KEY")!;
var proxy = Environment.GetEnvironmentVariable("PROXY_URL")!;
const string Starter = "https://starter.cdpfleet.com";
const string Api = "https://cdpfleet.com";
var http = new HttpClient();
string[] urls = new[] { 1, 2, 3 }.Select(n => $"https://books.toscrape.com/catalogue/page-{n}.html").ToArray();
// The scrape every variant runs: three catalogue pages, every book title.
async Task<JsonObject> Scrape(IBrowser browser, Stopwatch sw, long connectedMs)
{
var page = await browser.NewPageAsync();
var titles = new List<string>();
foreach (var url in urls)
{
// A shared proxy drops the odd connection: one retry.
try { await page.GotoAsync(url, new() { Timeout = 45000 }); }
catch (PlaywrightException) { await page.GotoAsync(url, new() { Timeout = 45000 }); }
titles.AddRange(await page.Locator("article.product_pod h3 a").EvaluateAllAsync<string[]>("as => as.map(a => a.getAttribute('title'))"));
}
return new JsonObject { ["books"] = titles.Count, ["first_title"] = titles[0], ["connected_ms"] = connectedMs, ["seconds"] = sw.Elapsed.TotalSeconds };
}
// ---- 1a. Plain Playwright: launch over HTTP yourself, then connect with the key header.
async Task<(int Status, string? RetryAfter, JsonObject? Body)> Launch(string k, object body)
{
using var req = new HttpRequestMessage(HttpMethod.Post, $"{Starter}/chromium/session") { Content = JsonContent.Create(body) };
req.Headers.Add("x-api-key", k);
using var res = await http.SendAsync(req);
JsonObject? data = null;
try { data = JsonNode.Parse(await res.Content.ReadAsStringAsync())?.AsObject(); } catch (JsonException) { }
return ((int)res.StatusCode, res.Headers.RetryAfter?.Delta?.TotalSeconds.ToString(), data);
}
async Task<JsonObject> Plain(IPlaywright pw)
{
var sw = Stopwatch.StartNew();
var r = await Launch(key, new { proxy, headless = true, record = false });
if (r.Status != 200) throw new Exception($"launch {r.Status} {r.Body}");
var browser = await pw.Chromium.ConnectAsync(r.Body!["wsUrl"]!.GetValue<string>(), new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try { return await Scrape(browser, sw, sw.ElapsedMilliseconds); }
finally { await browser.CloseAsync(); } // closing the connection ends the session
}
// ---- 1c. A connect URL built by hand: one Connect() launches the browser.
async Task<JsonObject> ConnectUrl(IPlaywright pw)
{
var sw = Stopwatch.StartNew();
var url = $"wss://router01.cdpfleet.com/connect/chromium?proxy={Uri.EscapeDataString(proxy)}&headless=true&record=false";
var browser = await pw.Chromium.ConnectAsync(url, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try { return await Scrape(browser, sw, sw.ElapsedMilliseconds); }
finally { await browser.CloseAsync(); }
}
// ---- 2. At the thread cap (Community: 2 threads). Hold both, then ask for a third.
async Task EndSession(string k, string id)
{
using var req = new HttpRequestMessage(HttpMethod.Delete, $"{Api}/v1/me/threads/{id}");
req.Headers.Add("x-api-key", k);
try { (await http.SendAsync(req)).Dispose(); } catch (HttpRequestException) { }
}
async Task<int> LiveThreads(string k)
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{Api}/v1/me/threads");
req.Headers.Add("x-api-key", k);
using var res = await http.SendAsync(req);
return JsonNode.Parse(await res.Content.ReadAsStringAsync())!["threads"]!.AsArray().Count;
}
// What the SDKs do for you: retry 429 (not the daily/hourly quota) and 503, waiting Retry-After, up to 120 s.
async Task<JsonObject> LaunchWithRetry(string k, object body)
{
var deadline = DateTime.UtcNow.AddSeconds(120);
for (var attempt = 1; ; attempt++)
{
var r = await Launch(k, body);
if (r.Status == 200) return r.Body!;
var code = r.Body?["error"]?.GetValue<string>() ?? "";
var quota = code is "daily_quota_exhausted" or "hourly_quota_exhausted";
var retryable = r.Status == 503 || (r.Status == 429 && !quota);
if (!retryable || DateTime.UtcNow > deadline) throw new Exception($"launch {r.Status} {code}");
var after = double.TryParse(r.RetryAfter, out var s) && s > 0 ? s : Math.Min(Math.Pow(2, attempt - 1), 10);
await Task.Delay(TimeSpan.FromSeconds(after + Random.Shared.NextDouble() * 0.5));
}
}
async Task<JsonObject> AtTheCap()
{
var body = new { headless = true, record = false };
var held = new List<string>();
for (var i = 0; i < 2; i++)
{
var r = await Launch(freeKey, body);
if (r.Status != 200) throw new Exception($"hold {r.Status} {r.Body}");
held.Add(r.Body!["sessionId"]!.GetValue<string>());
await Task.Delay(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.
var third = await Launch(freeKey, body);
await Task.Delay(1500);
// The hand-written loop: free a thread 5 s later and see when it returns.
var first = held[0];
held.RemoveAt(0);
var release = Task.Delay(5000).ContinueWith(_ => EndSession(freeKey, first)).Unwrap();
var sw = Stopwatch.StartNew();
JsonObject retry;
try
{
var s = await LaunchWithRetry(freeKey, body);
retry = new JsonObject { ["by"] = "hand-written", ["got_session"] = true, ["waited_s"] = sw.Elapsed.TotalSeconds };
await EndSession(freeKey, s["sessionId"]!.GetValue<string>()); // never connected: end it explicitly
}
catch (Exception e)
{
retry = new JsonObject { ["by"] = "hand-written", ["got_session"] = false, ["waited_s"] = sw.Elapsed.TotalSeconds, ["error"] = e.Message };
}
await release;
foreach (var id in held) await EndSession(freeKey, id);
await Task.Delay(3000);
return new JsonObject
{
["threads"] = 2,
["plain"] = new JsonObject
{
["status"] = third.Status,
["error"] = third.Body?["error"]?.GetValue<string>(),
["retry_after"] = third.RetryAfter,
["in_use"] = third.Body?["in_use"]?.GetValue<int>(),
["upgrade_url"] = third.Body?["upgrade"]?["url"]?.GetValue<string>(),
},
["retry"] = retry,
["threads_left"] = await LiveThreads(freeKey),
};
}
using var playwright = await Playwright.CreateAsync();
var output = new JsonObject
{
["language"] = "csharp",
["sdk"] = null,
["scrape"] = new JsonObject { ["plain"] = await Plain(playwright), ["sdk"] = null, ["connect_url"] = await ConnectUrl(playwright) },
["cap"] = await AtTheCap(),
["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd"),
};
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// go get github.com/playwright-community/[email protected] github.com/cdpfleet/cdpfleet/sdk/[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, COMMUNITY_API_KEY (a Community account: 2 threads, to reach the cap)
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"time"
cdpfleet "github.com/cdpfleet/cdpfleet/sdk/go"
"github.com/playwright-community/playwright-go"
)
const starter = "https://starter.cdpfleet.com"
const api = "https://cdpfleet.com"
var key = os.Getenv("CDPFLEET_API_KEY")
var freeKey = os.Getenv("COMMUNITY_API_KEY")
var proxy = os.Getenv("PROXY_URL")
var urls = []string{
"https://books.toscrape.com/catalogue/page-1.html",
"https://books.toscrape.com/catalogue/page-2.html",
"https://books.toscrape.com/catalogue/page-3.html",
}
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
type result struct {
Books int `json:"books"`
FirstTitle string `json:"first_title"`
ConnectedMs int64 `json:"connected_ms"`
Seconds float64 `json:"seconds"`
}
// The scrape every variant runs: three catalogue pages, every book title.
func scrape(browser playwright.Browser, start time.Time, connected time.Duration) result {
page := must(browser.NewPage())
var titles []string
for _, u := range urls {
if _, err := page.Goto(u, playwright.PageGotoOptions{Timeout: playwright.Float(45000)}); err != nil {
// A shared proxy drops the odd connection: one retry.
must(page.Goto(u, playwright.PageGotoOptions{Timeout: playwright.Float(45000)}))
}
got := must(page.Locator("article.product_pod h3 a").EvaluateAll("as => as.map(a => a.getAttribute('title'))"))
for _, t := range got.([]any) {
titles = append(titles, t.(string))
}
}
return result{len(titles), titles[0], connected.Milliseconds(), time.Since(start).Seconds()}
}
// ---- 1a. Plain Playwright: launch over HTTP yourself, then connect with the key header.
type launchReply struct {
status int
retryAfter string
body map[string]any
}
func plainLaunch(k string, body map[string]any) launchReply {
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", starter+"/chromium/session", bytes.NewReader(b))
req.Header.Set("x-api-key", k)
req.Header.Set("content-type", "application/json")
res := must(http.DefaultClient.Do(req))
defer res.Body.Close()
var data map[string]any
_ = json.NewDecoder(res.Body).Decode(&data)
return launchReply{res.StatusCode, res.Header.Get("retry-after"), data}
}
func plain(pw *playwright.Playwright) result {
t := time.Now()
r := plainLaunch(key, map[string]any{"proxy": proxy, "headless": true, "record": false})
if r.status != 200 {
log.Fatalf("launch %d %v", r.status, r.body)
}
browser := must(pw.Chromium.Connect(r.body["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}}))
defer browser.Close() // closing the connection ends the session
return scrape(browser, t, time.Since(t))
}
// ---- 1b. The SDK: Launch() (retries 429/503 as the API asks), Connect(), Close().
func withSDK(pw *playwright.Playwright, fleet *cdpfleet.Client) result {
t := time.Now()
session := must(fleet.Launch("chromium", cdpfleet.Options{"proxy": proxy, "headless": true, "record": false}))
defer session.Close()
browser := must(session.Connect(pw.Chromium))
return scrape(browser, t, time.Since(t))
}
// ---- 1c. The SDK's connect URL: one Connect() launches the browser (no HTTP call of your own).
func withConnectURL(pw *playwright.Playwright, fleet *cdpfleet.Client) result {
t := time.Now()
u := fleet.ConnectURL("chromium", cdpfleet.Options{"proxy": proxy, "headless": true, "record": false})
browser := must(pw.Chromium.Connect(u, playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": fleet.APIKey}}))
defer browser.Close()
return scrape(browser, t, time.Since(t))
}
// ---- 2. At the thread cap (Community: 2 threads). Hold both, then ask for a third.
func endSession(k, id string) {
req, _ := http.NewRequest("DELETE", api+"/v1/me/threads/"+id, nil)
req.Header.Set("x-api-key", k)
if res, err := http.DefaultClient.Do(req); err == nil {
res.Body.Close()
}
}
func liveThreads(k string) int {
req, _ := http.NewRequest("GET", api+"/v1/me/threads", nil)
req.Header.Set("x-api-key", k)
res := must(http.DefaultClient.Do(req))
defer res.Body.Close()
var data struct {
Threads []any `json:"threads"`
}
_ = json.NewDecoder(res.Body).Decode(&data)
return len(data.Threads)
}
func atTheCap() map[string]any {
var held []string
for i := 0; i < 2; i++ {
r := plainLaunch(freeKey, map[string]any{"headless": true, "record": false})
if r.status != 200 {
log.Fatalf("hold %d %v", r.status, r.body)
}
held = append(held, r.body["sessionId"].(string))
time.Sleep(1500 * time.Millisecond) // Community launches: at most 2 a second, 12 a minute
}
// Plain: the answer comes back at once and the retry is yours to write.
third := plainLaunch(freeKey, map[string]any{"headless": true, "record": false})
time.Sleep(1500 * time.Millisecond)
// SDK: the same launch waits; free a thread 5 s later and see when it returns.
freeFleet := must(cdpfleet.New(cdpfleet.WithAPIKey(freeKey)))
first := held[0]
held = held[1:]
time.AfterFunc(5*time.Second, func() { endSession(freeKey, first) })
t := time.Now()
retry := map[string]any{"by": "sdk"}
if s, err := freeFleet.Launch("chromium", cdpfleet.Options{"headless": true, "record": false}); err == nil {
retry["got_session"], retry["waited_s"] = true, time.Since(t).Seconds()
_ = s.End() // never connected: end it explicitly
} else {
var e *cdpfleet.Error
retry["got_session"], retry["waited_s"] = false, time.Since(t).Seconds()
if errors.As(err, &e) {
retry["error"] = e.Code
}
}
for _, id := range held {
endSession(freeKey, id)
}
time.Sleep(3 * time.Second)
var upgradeURL any
if u, ok := third.body["upgrade"].(map[string]any); ok {
upgradeURL = u["url"]
}
return map[string]any{
"threads": 2,
"plain": map[string]any{"status": third.status, "error": third.body["error"], "retry_after": third.retryAfter,
"in_use": third.body["in_use"], "upgrade_url": upgradeURL},
"retry": retry,
"threads_left": liveThreads(freeKey),
}
}
func main() {
pw := must(playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true}))
defer pw.Stop()
fleet := must(cdpfleet.New()) // reads CDPFLEET_API_KEY
out := map[string]any{"language": "go", "sdk": "cdpfleet 0.1.1 (Go module)"}
out["scrape"] = map[string]any{"plain": plain(pw), "sdk": withSDK(pw, fleet), "connect_url": withConnectURL(pw, fleet)}
out["cap"] = atTheCap()
out["measured_at"] = time.Now().Format("2006-01-02")
fmt.Println(string(must(json.MarshalIndent(out, "", " "))))
}
What we got
| Language | Plain Playwright | SDK launch() + connect() | Connect URL | Third launch at the cap | Launch that waits | Measured |
|---|---|---|---|---|---|---|
| node | 60 books · connected in 579 ms · 8.4 s | 60 books · connected in 370 ms · 11.2 s | 60 books · connected in 328 ms · 6.8 s | 429 threads_exceeded, Retry-After 1 s | SDK: session after 6.5 s | 2026-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
- The scrape itself doesn't change. All 15 variants on 2026-10-29 (plain, SDK and connect URL in Node, Python and Go; plain and connect URL in Java and C#) loaded the same 60 books. A browser was connected 0.3–0.7 s after the launch started in every variant but one (the first launch of the C# run, 2.0 s). The total time, 3.8 to 34.5 s, followed the proxy's page loads from run to run, not the way the browser was launched.
- The difference is at the cap. With both Community threads in use, a plain launch request got
429 threads_exceededat once, withRetry-After: 1,in_use: 2and anupgradeobject linking to /upgrade. Your own code has to decide what to do with that. The SDK'slaunch()kept trying asRetry-Afterasked and returned a session 5.5–6.9 s after it started, within about 2 s of the thread being freed at 5 s. - That retry is what you write yourself without an SDK. In Java and C# the same behaviour took a 14- and 15-line loop: retry
503and429, except the daily and hourly quota (they don't clear in seconds), waitRetry-Afteror back off, and give up after 120 s. It returned in 6.5–7.0 s, like the SDKs. The SDKs also add the key header when connecting, give you the session id, and end sessions onclose(). - A launched session holds a thread from the launch on: until it is closed, or, if nobody connects, for its 60-second connect window. The sessions held to reach the cap were never connected, and neither was the one the waiting launch returned, so each program ended them at once with
DELETE /v1/me/threads/:id(session.end()in the SDKs). Every run finished with no thread left running. - Which to use: plain Playwright is two steps and works in every language, which is fine for scripts that run one browser at a time. Use an SDK (Node, Python, Go) or the same retry loop when several workers share an account's threads and a refused launch should wait rather than fail. The connect URL removes the launch request altogether: see /docs/connect and /docs/sdk.