Cases / #26 · 2026-10-07 · Medium
Same Chrome, two doors: Playwright's protocol (wsUrl) or CDP (cdpUrl)
One Chrome launched twice — driven once through Playwright's own protocol and once over the Chrome DevTools Protocol — and what changes for your code and for the page.
Run on production on 2026-10-07: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
cdpfleet sessions can now be driven two ways. wsUrl speaks Playwright's own protocol: you need a Playwright client of the server's exact minor version, and you get everything Playwright offers. With "cdp": true you also get cdpUrl, a plain Chrome DevTools Protocol endpoint: Puppeteer, connectOverCDP in any Playwright version, chromedp, Rod and most agent frameworks connect there. Which should you use — is one faster, does one lose features, and does one give the automation away to the page?
What we used, and why
| What | Why |
|---|---|
chrome with "cdp": true | Real Google Chrome; the launch response then carries cdpUrl next to wsUrl. See CDP & Puppeteer. |
chromium.connect(wsUrl) | Playwright's own protocol. The client must match the server's minor version (1.60). |
chromium.connectOverCDP(cdpUrl) | Playwright on top of plain CDP — any recent client version works, in every language. |
page.on("console"), page.pdf() | Two features that depend on what the connection carries: console events and Chrome's PDF printer. |
| The stack-getter probe | A page logs an error whose stack getter records whether a debugger read it — the classic tell of a CDP client that has Runtime.enabled the page. |
How it works
- Launch Chrome with
"cdp": trueand connect withconnect(wsUrl); open example.com, listen for console messages, run the probe, trypage.pdf(), close. - Launch again and do the same through
connectOverCDP(cdpUrl). - Print one row per connection: connect time, browser version, console events, PDF, what the probe saw,
navigator.webdriver.
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;
// What a page can notice about the client driving it. A debugger that has Runtime.enable'd
// the page serializes logged errors, which reads their `stack` getter.
const PROBE = `(async () => {
let stackRead = false;
const e = new Error('probe');
Object.defineProperty(e, 'stack', { get() { stackRead = true; return ''; } });
console.debug(e);
await new Promise((r) => setTimeout(r, 100));
return { stack_read_by_debugger: stackRead, webdriver: navigator.webdriver };
})()`;
async function launch(cdp) {
const res = await fetch('https://starter.cdpfleet.com/chrome/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: 'new', cdp }),
});
if (!res.ok) throw new Error(`launch ${res.status} ${await res.text()}`);
return res.json();
}
async function measure(mode) {
const s = await launch(mode !== 'playwright protocol');
const t = Date.now();
const browser = mode === 'playwright protocol'
? await chromium.connect(s.wsUrl, { headers: { 'x-api-key': KEY } })
: await chromium.connectOverCDP(s.cdpUrl, { headers: { 'x-api-key': KEY } });
const connect_ms = Date.now() - t;
try {
const page = await browser.newPage();
const logged = [];
page.on('console', (m) => logged.push(m.type()));
for (let attempt = 1; ; attempt++) { // the proxy can drop a tunnel; retry
try { await page.goto('https://example.com/', { timeout: 60000 }); break; } catch (err) { if (attempt === 3) throw err; }
}
const seen = await page.evaluate(PROBE);
let pdf = false;
try { pdf = (await page.pdf()).length > 0; } catch { /* not over this connection */ }
return {
mode,
endpoint: mode === 'playwright protocol' ? 'wsUrl' : 'cdpUrl',
client_version_must_match: mode === 'playwright protocol',
connect_ms,
browser_version: browser.version(),
console_events: logged.includes('debug'),
pdf,
...seen,
};
} finally {
await browser.close();
}
}
console.log(JSON.stringify([await measure('playwright protocol'), await measure('connectOverCDP')], null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import time
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
# What a page can notice about the client driving it. A debugger that has Runtime.enable'd
# the page serializes logged errors, which reads their `stack` getter.
PROBE = """(async () => {
let stackRead = false;
const e = new Error('probe');
Object.defineProperty(e, 'stack', { get() { stackRead = true; return ''; } });
console.debug(e);
await new Promise((r) => setTimeout(r, 100));
return { stack_read_by_debugger: stackRead, webdriver: navigator.webdriver };
})()"""
def launch(cdp):
res = requests.post("https://starter.cdpfleet.com/chrome/session", headers={"x-api-key": KEY}, timeout=60,
json={"proxy": os.environ["PROXY_URL"], "headless": "new", "cdp": cdp})
if not res.ok:
raise SystemExit(f"launch {res.status_code} {res.text}")
return res.json()
def measure(p, mode):
s = launch(mode != "playwright protocol")
t = time.time()
if mode == "playwright protocol":
browser = p.chromium.connect(s["wsUrl"], headers={"x-api-key": KEY})
else:
browser = p.chromium.connect_over_cdp(s["cdpUrl"], headers={"x-api-key": KEY})
connect_ms = round((time.time() - t) * 1000)
try:
page = browser.new_page()
logged = []
page.on("console", lambda m: logged.append(m.type))
for attempt in range(1, 4): # the proxy can drop a tunnel; retry
try:
page.goto("https://example.com/", timeout=60000)
break
except Exception:
if attempt == 3:
raise
seen = page.evaluate(PROBE)
page.wait_for_timeout(200) # let the console event arrive
try:
pdf = len(page.pdf()) > 0
except Exception: # not over this connection
pdf = False
return {
"mode": mode,
"endpoint": "wsUrl" if mode == "playwright protocol" else "cdpUrl",
"client_version_must_match": mode == "playwright protocol",
"connect_ms": connect_ms,
"browser_version": browser.version,
"console_events": "debug" in logged,
"pdf": pdf,
**seen,
}
finally:
browser.close()
with sync_playwright() as p:
print(json.dumps([measure(p, "playwright protocol"), measure(p, "connectOverCDP")], 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
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().serializeNulls().create();
static final HttpClient HTTP = HttpClient.newHttpClient();
// What a page can notice about the client driving it. A debugger that has Runtime.enable'd
// the page serializes logged errors, which reads their `stack` getter.
static final String PROBE = """
(async () => {
let stackRead = false;
const e = new Error('probe');
Object.defineProperty(e, 'stack', { get() { stackRead = true; return ''; } });
console.debug(e);
await new Promise((r) => setTimeout(r, 100));
return { stack_read_by_debugger: stackRead, webdriver: navigator.webdriver };
})()""";
static JsonObject launch(boolean cdp) throws Exception {
JsonObject body = new JsonObject();
body.addProperty("proxy", System.getenv("PROXY_URL"));
body.addProperty("headless", "new");
body.addProperty("cdp", cdp);
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chrome/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(body))).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
return JsonParser.parseString(res.body()).getAsJsonObject();
}
static JsonObject measure(Playwright playwright, String mode) throws Exception {
boolean pwProtocol = mode.equals("playwright protocol");
JsonObject s = launch(!pwProtocol);
long t = System.currentTimeMillis();
Browser browser = pwProtocol
? playwright.chromium().connect(s.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)))
: playwright.chromium().connectOverCDP(s.get("cdpUrl").getAsString(), new BrowserType.ConnectOverCDPOptions().setHeaders(Map.of("x-api-key", KEY)));
long connectMs = System.currentTimeMillis() - t;
try {
Page page = browser.newPage();
List<String> logged = Collections.synchronizedList(new ArrayList<>());
page.onConsoleMessage(m -> logged.add(m.type()));
for (int attempt = 1; ; attempt++) { // the proxy can drop a tunnel; retry
try { page.navigate("https://example.com/", new Page.NavigateOptions().setTimeout(60000)); break; }
catch (PlaywrightException e) { if (attempt == 3) throw e; }
}
JsonObject seen = GSON.toJsonTree(page.evaluate(PROBE)).getAsJsonObject();
page.waitForTimeout(200); // let the console event arrive
boolean pdf;
try { pdf = page.pdf().length > 0; } catch (PlaywrightException e) { pdf = false; } // not over this connection
JsonObject row = new JsonObject();
row.addProperty("mode", mode);
row.addProperty("endpoint", pwProtocol ? "wsUrl" : "cdpUrl");
row.addProperty("client_version_must_match", pwProtocol);
row.addProperty("connect_ms", connectMs);
row.addProperty("browser_version", browser.version());
row.addProperty("console_events", logged.contains("debug"));
row.addProperty("pdf", pdf);
row.add("stack_read_by_debugger", seen.get("stack_read_by_debugger"));
row.add("webdriver", seen.get("webdriver"));
return row;
} finally {
browser.close();
}
}
public static void main(String[] args) throws Exception {
try (Playwright playwright = Playwright.create()) {
JsonArray out = new JsonArray();
out.add(measure(playwright, "playwright protocol"));
out.add(measure(playwright, "connectOverCDP"));
System.out.println(GSON.toJson(out));
}
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Diagnostics;
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
// What a page can notice about the client driving it. A debugger that has Runtime.enable'd
// the page serializes logged errors, which reads their `stack` getter.
const string Probe = """
(async () => {
let stackRead = false;
const e = new Error('probe');
Object.defineProperty(e, 'stack', { get() { stackRead = true; return ''; } });
console.debug(e);
await new Promise((r) => setTimeout(r, 100));
return { stack_read_by_debugger: stackRead, webdriver: navigator.webdriver };
})()
""";
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
async Task<JsonElement> Launch(bool cdp)
{
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chrome/session",
new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = "new", cdp });
if (!res.IsSuccessStatusCode) throw new Exception($"launch {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
return await res.Content.ReadFromJsonAsync<JsonElement>();
}
using var playwright = await Playwright.CreateAsync();
var headers = new Dictionary<string, string> { ["x-api-key"] = key };
async Task<JsonObject> Measure(string mode)
{
var pwProtocol = mode == "playwright protocol";
var s = await Launch(!pwProtocol);
var sw = Stopwatch.StartNew();
var browser = pwProtocol
? await playwright.Chromium.ConnectAsync(s.GetProperty("wsUrl").GetString()!, new BrowserTypeConnectOptions { Headers = headers })
: await playwright.Chromium.ConnectOverCDPAsync(s.GetProperty("cdpUrl").GetString()!, new BrowserTypeConnectOverCDPOptions { Headers = headers });
var connectMs = sw.ElapsedMilliseconds;
try
{
var page = await browser.NewPageAsync();
var logged = new List<string>();
page.Console += (_, m) => { lock (logged) logged.Add(m.Type); };
for (var attempt = 1; ; attempt++) // the proxy can drop a tunnel; retry
{
try { await page.GotoAsync("https://example.com/", new() { Timeout = 60000 }); break; }
catch (Exception e) when (e is PlaywrightException or TimeoutException) { if (attempt == 3) throw; }
}
var seen = JsonNode.Parse((await page.EvaluateAsync<JsonElement>(Probe)).GetRawText())!;
await page.WaitForTimeoutAsync(200); // let the console event arrive
bool pdf;
try { pdf = (await page.PdfAsync()).Length > 0; }
catch (PlaywrightException) { pdf = false; } // not over this connection
bool sawDebug;
lock (logged) sawDebug = logged.Contains("debug");
return new JsonObject
{
["mode"] = mode,
["endpoint"] = pwProtocol ? "wsUrl" : "cdpUrl",
["client_version_must_match"] = pwProtocol,
["connect_ms"] = connectMs,
["browser_version"] = browser.Version,
["console_events"] = sawDebug,
["pdf"] = pdf,
["stack_read_by_debugger"] = seen["stack_read_by_debugger"]?.DeepClone(),
["webdriver"] = seen["webdriver"]?.DeepClone(),
};
}
finally
{
await browser.CloseAsync();
}
}
var output = new JsonArray { await Measure("playwright protocol"), await Measure("connectOverCDP") };
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// 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"
"sync"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
// What a page can notice about the client driving it. A debugger that has Runtime.enable'd
// the page serializes logged errors, which reads their stack getter.
const probe = `(async () => {
let stackRead = false;
const e = new Error('probe');
Object.defineProperty(e, 'stack', { get() { stackRead = true; return ''; } });
console.debug(e);
await new Promise((r) => setTimeout(r, 100));
return { stack_read_by_debugger: stackRead, webdriver: navigator.webdriver };
})()`
type row struct {
Mode string `json:"mode"`
Endpoint string `json:"endpoint"`
ClientVersionMustMatch bool `json:"client_version_must_match"`
ConnectMs int64 `json:"connect_ms"`
BrowserVersion string `json:"browser_version"`
ConsoleEvents bool `json:"console_events"`
PDF bool `json:"pdf"`
StackReadByDebugger any `json:"stack_read_by_debugger"`
Webdriver any `json:"webdriver"`
}
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
func launch(cdp bool) map[string]any {
body, _ := json.Marshal(map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": "new", "cdp": cdp})
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/chrome/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res := must(http.DefaultClient.Do(req))
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
log.Fatalf("launch %s %s", res.Status, msg)
}
var s map[string]any
must(0, json.NewDecoder(res.Body).Decode(&s))
return s
}
func measure(pw *playwright.Playwright, mode string) row {
pwProtocol := mode == "playwright protocol"
s := launch(!pwProtocol)
headers := map[string]string{"x-api-key": key}
t := time.Now()
var browser playwright.Browser
if pwProtocol {
browser = must(pw.Chromium.Connect(s["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: headers}))
} else {
browser = must(pw.Chromium.ConnectOverCDP(s["cdpUrl"].(string), playwright.BrowserTypeConnectOverCDPOptions{Headers: headers}))
}
connectMs := time.Since(t).Milliseconds()
defer browser.Close()
page := must(browser.NewPage())
var mu sync.Mutex
logged := map[string]bool{}
page.OnConsole(func(m playwright.ConsoleMessage) {
mu.Lock()
logged[m.Type()] = true
mu.Unlock()
})
for attempt := 1; ; attempt++ { // the proxy can drop a tunnel; retry
_, err := page.Goto("https://example.com/", playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
if err == nil {
break
}
if attempt == 3 {
log.Fatal(err)
}
}
seen := must(page.Evaluate(probe)).(map[string]any)
page.WaitForTimeout(200) // let the console event arrive
pdf := false
if b, err := page.PDF(); err == nil { // not over every connection
pdf = len(b) > 0
}
mu.Lock()
sawDebug := logged["debug"]
mu.Unlock()
return row{
Mode: mode, Endpoint: map[bool]string{true: "wsUrl", false: "cdpUrl"}[pwProtocol], ClientVersionMustMatch: pwProtocol,
ConnectMs: connectMs, BrowserVersion: browser.Version(), ConsoleEvents: sawDebug, PDF: pdf,
StackReadByDebugger: seen["stack_read_by_debugger"], Webdriver: seen["webdriver"],
}
}
func main() {
pw := must(playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true}))
defer pw.Stop()
out := []row{measure(pw, "playwright protocol"), measure(pw, "connectOverCDP")}
text, _ := json.MarshalIndent(out, "", " ")
fmt.Println(string(text))
}
What we got
| Connection | Endpoint | Client version must match | Connect (ms) | Browser | Console events | Stack read by debugger | navigator.webdriver | |
|---|---|---|---|---|---|---|---|---|
| playwright protocol | wsUrl | yes | 32 | 155.0.8059.39 | yes | yes | no | no |
| connectOverCDP | cdpUrl | no | 52 | 155.0.8059.39 | yes | yes | no | no |
From the Node.js run on 2026-10-07. 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)
[
{
"mode": "playwright protocol",
"endpoint": "wsUrl",
"client_version_must_match": true,
"connect_ms": 32,
"browser_version": "155.0.8059.39",
"console_events": true,
"pdf": true,
"stack_read_by_debugger": false,
"webdriver": false
},
{
"mode": "connectOverCDP",
"endpoint": "cdpUrl",
"client_version_must_match": false,
"connect_ms": 52,
"browser_version": "155.0.8059.39",
"console_events": true,
"pdf": true,
"stack_read_by_debugger": false,
"webdriver": false
}
]Takeaways
- Same browser, same results: both connections reached the same Chrome build, got console events, printed a PDF and connected in a few dozen milliseconds. For most code the choice is about the client, not the browser.
connectOverCDPfrees you from version matching:connect()needs Playwright 1.60 exactly; over CDP any recent client works — and so do Puppeteer, chromedp, Rod and agent frameworks that only speak CDP.- Neither tripped the stack-getter probe here, and
navigator.webdriverwasfalseboth ways. (Puppeteer, measured separately on the same page, didn't trip it either, with or without a console listener.) Detection scripts look at more than this one signal — see headless tells and stealth builds. - Use
wsUrlwith the stealth builds: Patchright and Rebrowser keep their patches in Playwright's layer, which is why they refuse"cdp": true. - Closing either ends the session: you can connect to both at once (Playwright and Puppeteer on one Chrome), but disconnecting one closes the other.