[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #43 · 2026-10-13 · Benchmark · Medium · for [web scraping](https://cdpfleet.com/web-scraping)

# Can a page tell your automation client is listening? CDP and the console

The old Error.stack trick no longer fires, but console.log still costs more when Playwright listens over CDP. Patchright avoids it; Camoufox isn't CDP.

[Chromium](https://cdpfleet.com/docs/browsers/chromium) [Patchright](https://cdpfleet.com/docs/browsers/patchright) [Google Chrome](https://cdpfleet.com/docs/browsers/chrome) [Camoufox](https://cdpfleet.com/docs/browsers/camoufox)

Run on production on 2026-10-13: ✓ 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

Automation clients talk to Chromium over the DevTools protocol (CDP), and to deliver console messages they switch on its console and runtime domains (`Runtime.enable`). Anti-bot scripts have long tried to spot that from inside the page: a client that serializes logged objects reads things a normal browser never reads. Does that still work on today's browsers — and does it matter whether you connect with Playwright's protocol (`wsUrl`), over CDP (`cdpUrl`), with a console listener or without, with Patchright, or with Camoufox, which isn't CDP at all?

## What we used, and why

| What | Why |
| --- | --- |
| A self-contained test page | Loaded with `page.setContent`; its own script does the detecting, as a site's would, and writes the verdict into an attribute every client can read (Camoufox's `evaluate` runs in an isolated world). |
| The classic tell | Log an `Error` whose `stack` is a getter: a debugger that previews logged objects used to read it. |
| The same with `message` | An `Error` whose `message` is a getter. |
| The console cost | Log a 1,000-item array 40 times and time it against `JSON.stringify` of the same array (so the machine's speed cancels out); five rounds, the median ratio is kept. |
| Six setups | Playwright's Chromium and Patchright (the like-for-like pair: Patchright is that Chromium with Playwright's CDP traces removed), Chrome via `connect`, via `connectOverCDP` ([`cdp: true`](https://cdpfleet.com/docs/cdp)) with and without `page.on("console")`, and Camoufox. |

## How it works

1.  Launch each setup headless, one after the other, and open a page in a new context (the default one over CDP).
2.  Load the test page; wait for its verdict; count the console messages the client received.
3.  Record whether each getter was read and the console-cost ratio.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/43-cdp-detectability), 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, firefox } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const PROXY = process.env.PROXY_URL;

// Six ways to drive a remote browser. Chromium (Playwright's build) and Patchright share a
// Chromium base, so they're the like-for-like pair; Chrome is also driven over CDP, with and
// without a console listener; Camoufox is Firefox, driven over Playwright's Juggler, not CDP.
const SETUPS = [
  { label: 'Chromium, Playwright connect', engine: 'chromium', type: chromium, cdp: false, listen: true },
  { label: 'Patchright, Playwright connect', engine: 'patchright', type: chromium, cdp: false, listen: true },
  { label: 'Chrome, Playwright connect', engine: 'chrome', type: chromium, cdp: false, listen: true },
  { label: 'Chrome, connectOverCDP', engine: 'chrome', type: chromium, cdp: true, listen: true },
  { label: 'Chrome, connectOverCDP, no console listener', engine: 'chrome', type: chromium, cdp: true, listen: false },
  { label: 'Camoufox, Playwright connect', engine: 'camoufox', type: firefox, cdp: false, listen: true },
];

// What a detection script can do from inside the page, run as the page's own script:
//  - the classic tell: log an Error whose `stack` is a getter; a debugger client that previews
//    logged objects used to read it;
//  - an Error whose `message` is a getter, the same idea;
//  - the cost of console.log: with a client listening to the console, every logged object is
//    serialized for it. Logging a big object 40 times is timed against JSON.stringify of the
//    same object (so the machine's speed cancels out), five times; the median ratio is kept.
// The verdict is left in an attribute that every client can read.
const PAGE = `<html><body><p>console probe</p><script>
  const out = { stack_getter_read: false, message_getter_read: false };
  const e1 = new Error('probe');
  Object.defineProperty(e1, 'stack', { get() { out.stack_getter_read = true; return ''; } });
  console.debug(e1);
  const e2 = new Error('probe');
  Object.defineProperty(e2, 'message', { get() { out.message_getter_read = true; return ''; } });
  console.log(e2);
  console.log('hello-from-page');
  const big = Array.from({ length: 1000 }, (_, i) => ({ i, s: 'x'.repeat(40), a: [1, 2, 3, { d: i }] }));
  const ratios = [];
  for (let k = 0; k < 5; k++) {
    let t = performance.now();
    for (let i = 0; i < 40; i++) console.log(big);
    const logMs = performance.now() - t;
    t = performance.now();
    for (let i = 0; i < 40; i++) JSON.stringify(big);
    ratios.push(logMs / (performance.now() - t));
  }
  ratios.sort((a, b) => a - b);
  out.log_cost_ratio = Math.round(ratios[2] * 100) / 100;
  setTimeout(() => document.documentElement.setAttribute('data-out', JSON.stringify(out)), 1000);
</script></body></html>`;

async function run({ label, engine, type, cdp, listen }) {
  const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
    method: 'POST',
    headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
    body: JSON.stringify({ proxy: PROXY, headless: true, record: false, ...(cdp ? { cdp: true } : {}) }),
  });
  if (!res.ok) return { label, error: `launch ${res.status} ${await res.text()}` };
  const { wsUrl, cdpUrl } = await res.json();
  const browser = cdp
    ? await type.connectOverCDP(cdpUrl, { headers: { 'x-api-key': KEY } })
    : await type.connect(wsUrl, { headers: { 'x-api-key': KEY } });
  try {
    const context = cdp ? browser.contexts()[0] : await browser.newContext();
    const page = await context.newPage();
    let consoleEvents = 0;
    if (listen) page.on('console', (m) => { if (m.text().includes('hello-from-page')) consoleEvents++; });
    await page.setContent(PAGE);
    await page.waitForSelector('html[data-out]', { timeout: 30000 });
    const probe = JSON.parse(await page.evaluate(() => document.documentElement.getAttribute('data-out')));
    return {
      label, engine, connect: cdp ? 'connectOverCDP' : 'connect', console_listener: listen,
      console_events: listen ? consoleEvents : null, ...probe,
      evaluate_works: (await page.evaluate(() => 6 * 7)) === 42,
    };
  } finally {
    await browser.close();
  }
}

const setups = [];
for (const s of SETUPS) setups.push(await run(s)); // one at a time: an idle session ends after 2 minutes by default
console.log(JSON.stringify({ setups }, null, 2));
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os

import requests
from playwright.sync_api import sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]
PROXY = os.environ["PROXY_URL"]

# Six ways to drive a remote browser. Chromium (Playwright's build) and Patchright share a
# Chromium base, so they're the like-for-like pair; Chrome is also driven over CDP, with and
# without a console listener; Camoufox is Firefox, driven over Playwright's Juggler, not CDP.
SETUPS = [
    {"label": "Chromium, Playwright connect", "engine": "chromium", "family": "chromium", "cdp": False, "listen": True},
    {"label": "Patchright, Playwright connect", "engine": "patchright", "family": "chromium", "cdp": False, "listen": True},
    {"label": "Chrome, Playwright connect", "engine": "chrome", "family": "chromium", "cdp": False, "listen": True},
    {"label": "Chrome, connectOverCDP", "engine": "chrome", "family": "chromium", "cdp": True, "listen": True},
    {"label": "Chrome, connectOverCDP, no console listener", "engine": "chrome", "family": "chromium", "cdp": True, "listen": False},
    {"label": "Camoufox, Playwright connect", "engine": "camoufox", "family": "firefox", "cdp": False, "listen": True},
]

# What a detection script can do from inside the page, run as the page's own script:
#  - the classic tell: log an Error whose `stack` is a getter; a debugger client that previews
#    logged objects used to read it;
#  - an Error whose `message` is a getter, the same idea;
#  - the cost of console.log: with a client listening to the console, every logged object is
#    serialized for it. Logging a big object 40 times is timed against JSON.stringify of the
#    same object (so the machine's speed cancels out), five times; the median ratio is kept.
# The verdict is left in an attribute that every client can read.
PAGE = """<html><body><p>console probe</p><script>
  const out = { stack_getter_read: false, message_getter_read: false };
  const e1 = new Error('probe');
  Object.defineProperty(e1, 'stack', { get() { out.stack_getter_read = true; return ''; } });
  console.debug(e1);
  const e2 = new Error('probe');
  Object.defineProperty(e2, 'message', { get() { out.message_getter_read = true; return ''; } });
  console.log(e2);
  console.log('hello-from-page');
  const big = Array.from({ length: 1000 }, (_, i) => ({ i, s: 'x'.repeat(40), a: [1, 2, 3, { d: i }] }));
  const ratios = [];
  for (let k = 0; k < 5; k++) {
    let t = performance.now();
    for (let i = 0; i < 40; i++) console.log(big);
    const logMs = performance.now() - t;
    t = performance.now();
    for (let i = 0; i < 40; i++) JSON.stringify(big);
    ratios.push(logMs / (performance.now() - t));
  }
  ratios.sort((a, b) => a - b);
  out.log_cost_ratio = Math.round(ratios[2] * 100) / 100;
  setTimeout(() => document.documentElement.setAttribute('data-out', JSON.stringify(out)), 1000);
</script></body></html>"""

def run(p, s):
    body = {"proxy": PROXY, "headless": True, "record": False, **({"cdp": True} if s["cdp"] else {})}
    res = requests.post(f"https://starter.cdpfleet.com/{s['engine']}/session", headers={"x-api-key": KEY}, json=body, timeout=60)
    if not res.ok:
        return {"label": s["label"], "error": f"launch {res.status_code} {res.text}"}
    session = res.json()
    browser_type = getattr(p, s["family"])
    browser = (browser_type.connect_over_cdp(session["cdpUrl"], headers={"x-api-key": KEY}) if s["cdp"]
               else browser_type.connect(session["wsUrl"], headers={"x-api-key": KEY}))
    try:
        context = browser.contexts[0] if s["cdp"] else browser.new_context()
        page = context.new_page()
        events = [0]
        if s["listen"]:
            page.on("console", lambda m: events.__setitem__(0, events[0] + ("hello-from-page" in m.text)))
        page.set_content(PAGE)
        page.wait_for_selector("html[data-out]", timeout=30000)
        probe = json.loads(page.evaluate("() => document.documentElement.getAttribute('data-out')"))
        return {
            "label": s["label"], "engine": s["engine"], "connect": "connectOverCDP" if s["cdp"] else "connect",
            "console_listener": s["listen"], "console_events": events[0] if s["listen"] else None, **probe,
            "evaluate_works": page.evaluate("() => 6 * 7") == 42,
        }
    finally:
        browser.close()

with sync_playwright() as p:
    setups = [run(p, s) for s in SETUPS]  # one at a time: an idle session ends after 2 minutes by default
print(json.dumps({"setups": setups}, 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 String PROXY = System.getenv("PROXY_URL");
  static final Gson GSON = new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create();

  // Six ways to drive a remote browser. Chromium (Playwright's build) and Patchright share a
  // Chromium base, so they're the like-for-like pair; Chrome is also driven over CDP, with and
  // without a console listener; Camoufox is Firefox, driven over Playwright's Juggler, not CDP.
  record Setup(String label, String engine, String family, boolean cdp, boolean listen) {}
  static final Setup[] SETUPS = {
    new Setup("Chromium, Playwright connect", "chromium", "chromium", false, true),
    new Setup("Patchright, Playwright connect", "patchright", "chromium", false, true),
    new Setup("Chrome, Playwright connect", "chrome", "chromium", false, true),
    new Setup("Chrome, connectOverCDP", "chrome", "chromium", true, true),
    new Setup("Chrome, connectOverCDP, no console listener", "chrome", "chromium", true, false),
    new Setup("Camoufox, Playwright connect", "camoufox", "firefox", false, true),
  };

  // What a detection script can do from inside the page, run as the page's own script:
  //  - the classic tell: log an Error whose `stack` is a getter; a debugger client that previews
  //    logged objects used to read it;
  //  - an Error whose `message` is a getter, the same idea;
  //  - the cost of console.log: with a client listening to the console, every logged object is
  //    serialized for it. Logging a big object 40 times is timed against JSON.stringify of the
  //    same object (so the machine's speed cancels out), five times; the median ratio is kept.
  // The verdict is left in an attribute that every client can read.
  static final String PAGE = """
      <html><body><p>console probe</p><script>
        const out = { stack_getter_read: false, message_getter_read: false };
        const e1 = new Error('probe');
        Object.defineProperty(e1, 'stack', { get() { out.stack_getter_read = true; return ''; } });
        console.debug(e1);
        const e2 = new Error('probe');
        Object.defineProperty(e2, 'message', { get() { out.message_getter_read = true; return ''; } });
        console.log(e2);
        console.log('hello-from-page');
        const big = Array.from({ length: 1000 }, (_, i) => ({ i, s: 'x'.repeat(40), a: [1, 2, 3, { d: i }] }));
        const ratios = [];
        for (let k = 0; k < 5; k++) {
          let t = performance.now();
          for (let i = 0; i < 40; i++) console.log(big);
          const logMs = performance.now() - t;
          t = performance.now();
          for (let i = 0; i < 40; i++) JSON.stringify(big);
          ratios.push(logMs / (performance.now() - t));
        }
        ratios.sort((a, b) => a - b);
        out.log_cost_ratio = Math.round(ratios[2] * 100) / 100;
        setTimeout(() => document.documentElement.setAttribute('data-out', JSON.stringify(out)), 1000);
      </script></body></html>
      """;

  static JsonObject run(Playwright pw, Setup s) throws Exception {
    JsonObject body = new JsonObject();
    body.addProperty("proxy", PROXY);
    body.addProperty("headless", true);
    body.addProperty("record", false);
    if (s.cdp()) body.addProperty("cdp", true);
    HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + s.engine() + "/session"))
        .header("x-api-key", KEY).header("content-type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
    JsonObject out = new JsonObject();
    out.addProperty("label", s.label());
    if (res.statusCode() != 200) {
      out.addProperty("error", "launch " + res.statusCode() + " " + res.body());
      return out;
    }
    JsonObject session = JsonParser.parseString(res.body()).getAsJsonObject();
    BrowserType type = s.family().equals("firefox") ? pw.firefox() : pw.chromium();
    Browser browser = s.cdp()
        ? type.connectOverCDP(session.get("cdpUrl").getAsString(), new BrowserType.ConnectOverCDPOptions().setHeaders(Map.of("x-api-key", KEY)))
        : type.connect(session.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
    try {
      BrowserContext context = s.cdp() ? browser.contexts().get(0) : browser.newContext();
      Page page = context.newPage();
      int[] events = {0};
      if (s.listen()) page.onConsoleMessage(m -> { if (m.text().contains("hello-from-page")) events[0]++; });
      page.setContent(PAGE);
      page.waitForSelector("html[data-out]", new Page.WaitForSelectorOptions().setTimeout(30000));
      JsonObject probe = JsonParser.parseString((String) page.evaluate("() => document.documentElement.getAttribute('data-out')")).getAsJsonObject();
      out.addProperty("engine", s.engine());
      out.addProperty("connect", s.cdp() ? "connectOverCDP" : "connect");
      out.addProperty("console_listener", s.listen());
      if (s.listen()) out.addProperty("console_events", events[0]); else out.add("console_events", JsonNull.INSTANCE);
      for (Map.Entry<String, JsonElement> e : probe.entrySet()) out.add(e.getKey(), e.getValue());
      out.addProperty("evaluate_works", ((Number) page.evaluate("() => 6 * 7")).intValue() == 42);
      return out;
    } finally {
      browser.close();
    }
  }

  public static void main(String[] args) throws Exception {
    JsonArray setups = new JsonArray();
    try (Playwright pw = Playwright.create()) {
      for (Setup s : SETUPS) setups.add(run(pw, s)); // one at a time: an idle session ends after 2 minutes by default
    }
    JsonObject result = new JsonObject();
    result.add("setups", setups);
    System.out.println(GSON.toJson(result));
  }
}
```

```csharp
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
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")!;
var proxy = Environment.GetEnvironmentVariable("PROXY_URL")!;

// Six ways to drive a remote browser. Chromium (Playwright's build) and Patchright share a
// Chromium base, so they're the like-for-like pair; Chrome is also driven over CDP, with and
// without a console listener; Camoufox is Firefox, driven over Playwright's Juggler, not CDP.
var setups = new (string Label, string Engine, string Family, bool Cdp, bool Listen)[]
{
    ("Chromium, Playwright connect", "chromium", "chromium", false, true),
    ("Patchright, Playwright connect", "patchright", "chromium", false, true),
    ("Chrome, Playwright connect", "chrome", "chromium", false, true),
    ("Chrome, connectOverCDP", "chrome", "chromium", true, true),
    ("Chrome, connectOverCDP, no console listener", "chrome", "chromium", true, false),
    ("Camoufox, Playwright connect", "camoufox", "firefox", false, true),
};

// What a detection script can do from inside the page, run as the page's own script:
//  - the classic tell: log an Error whose `stack` is a getter; a debugger client that previews
//    logged objects used to read it;
//  - an Error whose `message` is a getter, the same idea;
//  - the cost of console.log: with a client listening to the console, every logged object is
//    serialized for it. Logging a big object 40 times is timed against JSON.stringify of the
//    same object (so the machine's speed cancels out), five times; the median ratio is kept.
// The verdict is left in an attribute that every client can read.
const string Page = """
    <html><body><p>console probe</p><script>
      const out = { stack_getter_read: false, message_getter_read: false };
      const e1 = new Error('probe');
      Object.defineProperty(e1, 'stack', { get() { out.stack_getter_read = true; return ''; } });
      console.debug(e1);
      const e2 = new Error('probe');
      Object.defineProperty(e2, 'message', { get() { out.message_getter_read = true; return ''; } });
      console.log(e2);
      console.log('hello-from-page');
      const big = Array.from({ length: 1000 }, (_, i) => ({ i, s: 'x'.repeat(40), a: [1, 2, 3, { d: i }] }));
      const ratios = [];
      for (let k = 0; k < 5; k++) {
        let t = performance.now();
        for (let i = 0; i < 40; i++) console.log(big);
        const logMs = performance.now() - t;
        t = performance.now();
        for (let i = 0; i < 40; i++) JSON.stringify(big);
        ratios.push(logMs / (performance.now() - t));
      }
      ratios.sort((a, b) => a - b);
      out.log_cost_ratio = Math.round(ratios[2] * 100) / 100;
      setTimeout(() => document.documentElement.setAttribute('data-out', JSON.stringify(out)), 1000);
    </script></body></html>
    """;

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();

async Task<JsonObject> Run(string label, string engine, string family, bool cdp, bool listen)
{
    var body = new JsonObject { ["proxy"] = proxy, ["headless"] = true, ["record"] = false };
    if (cdp) body["cdp"] = true;
    var res = await http.PostAsync($"https://starter.cdpfleet.com/{engine}/session",
        new StringContent(body.ToJsonString(), System.Text.Encoding.UTF8, "application/json"));
    if (!res.IsSuccessStatusCode) return new JsonObject { ["label"] = label, ["error"] = $"launch {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}" };
    var session = await res.Content.ReadFromJsonAsync<JsonElement>();
    var type = family == "firefox" ? playwright.Firefox : playwright.Chromium;
    var headers = new Dictionary<string, string> { ["x-api-key"] = key };
    var browser = cdp
        ? await type.ConnectOverCDPAsync(session.GetProperty("cdpUrl").GetString()!, new() { Headers = headers })
        : await type.ConnectAsync(session.GetProperty("wsUrl").GetString()!, new() { Headers = headers });
    try
    {
        var context = cdp ? browser.Contexts[0] : await browser.NewContextAsync();
        var page = await context.NewPageAsync();
        var events = 0;
        if (listen) page.Console += (_, m) => { if (m.Text.Contains("hello-from-page")) events++; };
        await page.SetContentAsync(Page);
        await page.WaitForSelectorAsync("html[data-out]", new() { Timeout = 30000 });
        var probe = JsonNode.Parse(await page.EvaluateAsync<string>("() => document.documentElement.getAttribute('data-out')"))!.AsObject();
        var outp = new JsonObject
        {
            ["label"] = label, ["engine"] = engine, ["connect"] = cdp ? "connectOverCDP" : "connect",
            ["console_listener"] = listen, ["console_events"] = listen ? events : (int?)null,
        };
        foreach (var (k, v) in probe) outp[k] = v?.DeepClone();
        outp["evaluate_works"] = await page.EvaluateAsync<int>("() => 6 * 7") == 42;
        return outp;
    }
    finally
    {
        await browser.CloseAsync();
    }
}

var rows = new JsonArray();
foreach (var s in setups) rows.Add(await Run(s.Label, s.Engine, s.Family, s.Cdp, s.Listen)); // one at a time: an idle session ends after 2 minutes by default
Console.WriteLine(new JsonObject { ["setups"] = rows }.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"
	"strings"

	"github.com/playwright-community/playwright-go"
)

var key = os.Getenv("CDPFLEET_API_KEY")
var proxy = os.Getenv("PROXY_URL")

// Six ways to drive a remote browser. Chromium (Playwright's build) and Patchright share a
// Chromium base, so they're the like-for-like pair; Chrome is also driven over CDP, with and
// without a console listener; Camoufox is Firefox, driven over Playwright's Juggler, not CDP.
type setup struct {
	label, engine, family string
	cdp, listen           bool
}

var setups = []setup{
	{"Chromium, Playwright connect", "chromium", "chromium", false, true},
	{"Patchright, Playwright connect", "patchright", "chromium", false, true},
	{"Chrome, Playwright connect", "chrome", "chromium", false, true},
	{"Chrome, connectOverCDP", "chrome", "chromium", true, true},
	{"Chrome, connectOverCDP, no console listener", "chrome", "chromium", true, false},
	{"Camoufox, Playwright connect", "camoufox", "firefox", false, true},
}

// What a detection script can do from inside the page, run as the page's own script:
//   - the classic tell: log an Error whose `stack` is a getter; a debugger client that previews
//     logged objects used to read it;
//   - an Error whose `message` is a getter, the same idea;
//   - the cost of console.log: with a client listening to the console, every logged object is
//     serialized for it. Logging a big object 40 times is timed against JSON.stringify of the
//     same object (so the machine's speed cancels out), five times; the median ratio is kept.
//
// The verdict is left in an attribute that every client can read.
const page = `<html><body><p>console probe</p><script>
  const out = { stack_getter_read: false, message_getter_read: false };
  const e1 = new Error('probe');
  Object.defineProperty(e1, 'stack', { get() { out.stack_getter_read = true; return ''; } });
  console.debug(e1);
  const e2 = new Error('probe');
  Object.defineProperty(e2, 'message', { get() { out.message_getter_read = true; return ''; } });
  console.log(e2);
  console.log('hello-from-page');
  const big = Array.from({ length: 1000 }, (_, i) => ({ i, s: 'x'.repeat(40), a: [1, 2, 3, { d: i }] }));
  const ratios = [];
  for (let k = 0; k < 5; k++) {
    let t = performance.now();
    for (let i = 0; i < 40; i++) console.log(big);
    const logMs = performance.now() - t;
    t = performance.now();
    for (let i = 0; i < 40; i++) JSON.stringify(big);
    ratios.push(logMs / (performance.now() - t));
  }
  ratios.sort((a, b) => a - b);
  out.log_cost_ratio = Math.round(ratios[2] * 100) / 100;
  setTimeout(() => document.documentElement.setAttribute('data-out', JSON.stringify(out)), 1000);
</script></body></html>`

// Field order matches the other languages' output.
type result struct {
	Label             string  `json:"label"`
	Error             string  `json:"error,omitempty"`
	Engine            string  `json:"engine,omitempty"`
	Connect           string  `json:"connect,omitempty"`
	ConsoleListener   bool    `json:"console_listener"`
	ConsoleEvents     *int    `json:"console_events"`
	StackGetterRead   bool    `json:"stack_getter_read"`
	MessageGetterRead bool    `json:"message_getter_read"`
	LogCostRatio      float64 `json:"log_cost_ratio"`
	EvaluateWorks     bool    `json:"evaluate_works"`
}

func launch(engine string, cdp bool) (map[string]any, error) {
	opts := map[string]any{"proxy": proxy, "headless": true, "record": false}
	if cdp {
		opts["cdp"] = true
	}
	body, _ := json.Marshal(opts)
	req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/session", bytes.NewReader(body))
	req.Header.Set("x-api-key", key)
	req.Header.Set("content-type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.StatusCode != 200 {
		b, _ := io.ReadAll(res.Body)
		return nil, fmt.Errorf("launch %d %s", res.StatusCode, strings.TrimSpace(string(b)))
	}
	var session map[string]any
	return session, json.NewDecoder(res.Body).Decode(&session)
}

func probe(pw *playwright.Playwright, s setup) (result, error) {
	out := result{Label: s.label}
	session, err := launch(s.engine, s.cdp)
	if err != nil {
		out.Error = err.Error()
		return out, nil
	}
	browserType := pw.Chromium
	if s.family == "firefox" {
		browserType = pw.Firefox
	}
	headers := map[string]string{"x-api-key": key}
	var browser playwright.Browser
	if s.cdp {
		browser, err = browserType.ConnectOverCDP(session["cdpUrl"].(string), playwright.BrowserTypeConnectOverCDPOptions{Headers: headers})
	} else {
		browser, err = browserType.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: headers})
	}
	if err != nil {
		return out, err
	}
	defer browser.Close()
	var context playwright.BrowserContext
	if s.cdp {
		context = browser.Contexts()[0]
	} else if context, err = browser.NewContext(); err != nil {
		return out, err
	}
	pg, err := context.NewPage()
	if err != nil {
		return out, err
	}
	events := 0
	if s.listen {
		pg.OnConsole(func(m playwright.ConsoleMessage) {
			if strings.Contains(m.Text(), "hello-from-page") {
				events++
			}
		})
	}
	if err := pg.SetContent(page); err != nil {
		return out, err
	}
	if _, err := pg.WaitForSelector("html[data-out]", playwright.PageWaitForSelectorOptions{Timeout: playwright.Float(30000)}); err != nil {
		return out, err
	}
	raw, err := pg.Evaluate("() => document.documentElement.getAttribute('data-out')")
	if err != nil {
		return out, err
	}
	var p struct {
		StackGetterRead   bool    `json:"stack_getter_read"`
		MessageGetterRead bool    `json:"message_getter_read"`
		LogCostRatio      float64 `json:"log_cost_ratio"`
	}
	if err := json.Unmarshal([]byte(raw.(string)), &p); err != nil {
		return out, err
	}
	answer, err := pg.Evaluate("() => 6 * 7")
	if err != nil {
		return out, err
	}
	out.Engine, out.ConsoleListener = s.engine, s.listen
	out.Connect = "connect"
	if s.cdp {
		out.Connect = "connectOverCDP"
	}
	if s.listen {
		out.ConsoleEvents = &events
	}
	out.StackGetterRead, out.MessageGetterRead, out.LogCostRatio = p.StackGetterRead, p.MessageGetterRead, p.LogCostRatio
	out.EvaluateWorks = fmt.Sprint(answer) == "42"
	return out, nil
}

// run returns errors instead of exiting, so deferred Close and Stop calls still run.
func run() error {
	pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
	if err != nil {
		return err
	}
	defer pw.Stop()
	results := []result{}
	for _, s := range setups { // one at a time: an idle session ends after 2 minutes by default
		r, err := probe(pw, s)
		if err != nil {
			return fmt.Errorf("%s: %w", s.label, err)
		}
		results = append(results, r)
	}
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	enc.SetIndent("", "  ")
	if err := enc.Encode(map[string]any{"setups": results}); err != nil {
		return err
	}
	fmt.Print(buf.String())
	return nil
}

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}
```

## What we got

| Setup | Error.stack getter read | Error.message getter read | console.log ÷ JSON.stringify | Console events received |
| --- | --- | --- | --- | --- |
| Chromium, Playwright connect | no | yes | 3.98× | 1 |
| Patchright, Playwright connect | no | yes | 2.76× | 0 |
| Chrome, Playwright connect | no | yes | 3.98× | 1 |
| Chrome, connectOverCDP | no | yes | 4.23× | 1 |
| Chrome, connectOverCDP, no console listener | no | yes | 4.75× | (no listener) |
| Camoufox, Playwright connect | no | no | 0.14× | 1 |

From the Node.js run on 2026-10-13. 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)

```
{
  "setups": [
    {
      "label": "Chromium, Playwright connect",
      "engine": "chromium",
      "connect": "connect",
      "console_listener": true,
      "console_events": 1,
      "stack_getter_read": false,
      "message_getter_read": true,
      "log_cost_ratio": 3.98,
      "evaluate_works": true
    },
    {
      "label": "Patchright, Playwright connect",
      "engine": "patchright",
      "connect": "connect",
      "console_listener": true,
      "console_events": 0,
      "stack_getter_read": false,
      "message_getter_read": true,
      "log_cost_ratio": 2.76,
      "evaluate_works": true
    },
    {
      "label": "Chrome, Playwright connect",
      "engine": "chrome",
      "connect": "connect",
      "console_listener": true,
      "console_events": 1,
      "stack_getter_read": false,
      "message_getter_read": true,
      "log_cost_ratio": 3.98,
      "evaluate_works": true
    },
    {
      "label": "Chrome, connectOverCDP",
      "engine": "chrome",
      "connect": "connectOverCDP",
      "console_listener": true,
      "console_events": 1,
      "stack_getter_read": false,
      "message_getter_read": true,
      "log_cost_ratio": 4.23,
      "evaluate_works": true
    },
    {
      "label": "Chrome, connectOverCDP, no console listener",
      "engine": "chrome",
      "connect": "connectOverCDP",
      "console_listener": false,
      "console_events": null,
      "stack_getter_read": false,
      "message_getter_read": true,
      "log_cost_ratio": 4.75,
      "evaluate_works": true
    },
    {
      "label": "Camoufox, Playwright connect",
      "engine": "camoufox",
      "connect": "connect",
      "console_listener": true,
      "console_events": 1,
      "stack_getter_read": false,
      "message_getter_read": false,
      "log_cost_ratio": 0.14,
      "evaluate_works": true
    }
  ]
}
```

## Takeaways

-   **The classic tell is gone.** The `Error.stack` getter was read in none of the six setups, in every run and language — not with Playwright's protocol, not over CDP, not on Chrome 155 or Playwright's Chromium. A detection script still relying on it sees nothing.
-   **Getter tricks now catch the browser, not the client.** The `Error.message` getter was read by every Chromium build — Patchright too, which delivers no console events at all — so it fires whether or not anyone is listening. It tells a site "this is Chromium", nothing about automation. Camoufox read neither getter.
-   **The cost is still there.** Logging a big object costs more when Playwright's client receives the console: 3.5–4.0× a `JSON.stringify` on Playwright's Chromium and 3.1–4.8× on Chrome, against 2.6–2.8× on Patchright — the same Chromium base with Playwright's CDP traces removed. Across the five runs Patchright was always the cheapest Chromium setup.
-   **Connecting over CDP or dropping the listener doesn't help.** Chrome via `connectOverCDP` cost the same as via `connect`, and without any `page.on("console")` it still cost 3.9–4.8×: Playwright switches the runtime domain on regardless, because `evaluate` needs it.
-   **It's a timing signal, not a flag.** One launch's ratio moved by up to 1× between runs (each setup ran on another machine), so a site can only use it statistically. If a target scores this kind of signal, use [Patchright](https://cdpfleet.com/docs/compare/patchright-vs-rebrowser) (no console events, by design) or Camoufox (Firefox over Playwright's own protocol, 0.1–0.2× here — a different engine, so not comparable with the Chromium numbers). The other classic tells are in [case 16](https://cdpfleet.com/docs/cases/headless-tells); CDP access itself is on the [CDP page](https://cdpfleet.com/docs/cdp).

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