[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #26 · 2026-10-07 · Example · Medium · for [testing](https://cdpfleet.com/cross-browser-testing), [ai agents](https://cdpfleet.com/ai-agents)

# 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.

[Google Chrome](https://cdpfleet.com/docs/browsers/chrome)

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](https://cdpfleet.com/docs/cdp). |
| `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.enable`d the page. |

## How it works

1.  Launch Chrome with `"cdp": true` and connect with `connect(wsUrl)`; open example.com, listen for console messages, run the probe, try `page.pdf()`, close.
2.  Launch again and do the same through `connectOverCDP(cdpUrl)`.
3.  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](https://github.com/cdpfleet/cdpfleet/tree/main/cases/26-playwright-vs-cdp), 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:pass@proxy.example.com:8000`

 

```node
// npm install playwright@1.60.0
// 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));
```

```python
# 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))
```

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

```csharp
// 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
// go get github.com/playwright-community/playwright-go@v0.6000.0
// 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 | PDF | 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.
-   **`connectOverCDP` frees 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.webdriver` was `false` both 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](https://cdpfleet.com/docs/cases/headless-tells) and [stealth builds](https://cdpfleet.com/docs/cases/stealth-builds).
-   **Use `wsUrl` with 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.

---
Source: https://cdpfleet.com/docs/cases/playwright-vs-cdp · Markdown: https://cdpfleet.com/docs/cases/playwright-vs-cdp.md · All docs for AI tools: https://cdpfleet.com/llms.txt
