cdpfleet Docs GitHub Dashboard

Cases / #43 · 2026-10-13 · Medium

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 Patchright Google Chrome Camoufox

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

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

WhatWhy
A self-contained test pageLoaded 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 tellLog an Error whose stack is a getter: a debugger that previews logged objects used to read it.
The same with messageAn Error whose message is a getter.
The console costLog 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 setupsPlaywright'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) 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, with the raw output). Set these environment variables first:

// npm install [email protected]
// 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 60 s
console.log(JSON.stringify({ setups }, null, 2));

What we got

SetupError.stack getter readError.message getter readconsole.log ÷ JSON.stringifyConsole events received
Chromium, Playwright connectnoyes3.98×1
Patchright, Playwright connectnoyes2.76×0
Chrome, Playwright connectnoyes3.98×1
Chrome, connectOverCDPnoyes4.23×1
Chrome, connectOverCDP, no console listenernoyes4.75×(no listener)
Camoufox, Playwright connectnono0.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