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
| 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) with and without page.on("console"), and Camoufox. |
How it works
- Launch each setup headless, one after the other, and open a page in a new context (the default one over CDP).
- Load the test page; wait for its verdict; count the console messages the client received.
- 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:
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, 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));
# 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 60 s
print(json.dumps({"setups": setups}, 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 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 60 s
}
JsonObject result = new JsonObject();
result.add("setups", setups);
System.out.println(GSON.toJson(result));
}
}
// 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 60 s
Console.WriteLine(new JsonObject { ["setups"] = rows }.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"
"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 60 s
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.stackgetter 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.messagegetter 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.stringifyon 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
connectOverCDPcost the same as viaconnect, and without anypage.on("console")it still cost 3.9–4.8×: Playwright switches the runtime domain on regardless, becauseevaluateneeds 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 (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; CDP access itself is on the CDP page.