cdpfleet Docs GitHub Dashboard

Cases / #16 · 2026-10-04 · Medium

What headless gives away, what headful fixes, and what neither does

Twelve classic detection signals read from inside the page in headless Chromium, headful Chromium, Patchright and Camoufox. Headful removes the obvious tells; the GPU and the host still talk.

Chromium Patchright Camoufox

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

The problem

Detection scripts don't need your TLS fingerprint to know a browser is automated: a handful of JavaScript properties give headless browsers away in a millisecond — HeadlessChrome in the user agent, an empty plugin list, a notification permission that says *denied* while the permissions API says *prompt*, a zero-sized outer window, navigator.webdriver. Running headful (the fleet's default, 2 threads) is the usual fix. But which signals does it actually fix, what do the stealth builds change on top, and what still identifies a server-side browser?

What we used, and why

WhatWhy
chromium with headless: "new" and with headless: falseThe same build, with and without a display, so the difference is the display alone.
patchright, headfulPlaywright's Chromium with the automation leaks patched — what changes beyond headful.
camoufox, headful, os: "windows"A build that spoofs the device at the C++ level: screen, GPU, cores and all.
main_world_eval: true and the mw: prefixCamoufox runs page.evaluate in an isolated world; the page's own world is what a detection script sees.
navigator.permissions.query vs Notification.permissionThe classic headless contradiction: *prompt* from one API, *denied* from the other.
WEBGL_debug_renderer_infoThe unmasked GPU renderer string — the signal that outlives headful.

How it works

  1. Launch the four browsers through the same proxy.
  2. Open a page and read twelve properties the way a detection script would: user agent, navigator.webdriver, plugins, the notification contradiction, outer window size, screen, WebGL renderer, cores, memory, languages, window.chrome.
  3. Count which of the five hard tells fire for each browser and print one row per browser.

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;

// Four ways to run a browser; the same twelve signals read from inside the page.
const TARGETS = [
  { label: 'Chromium, headless', engine: 'chromium', type: chromium, body: { headless: 'new' } },
  { label: 'Chromium, headful', engine: 'chromium', type: chromium, body: { headless: false } },
  { label: 'Patchright, headful', engine: 'patchright', type: chromium, body: { headless: false } },
  // Camoufox: read the page's own world ("mw:" needs main_world_eval), like a site would.
  { label: 'Camoufox, headful', engine: 'camoufox', type: firefox, body: { headless: false, os: 'windows', main_world_eval: true }, prefix: 'mw:' },
];

// The classic headless and automation tells, read the way a detection script reads them.
const SIGNALS = `(async () => {
  let query = null;
  try { query = (await navigator.permissions.query({ name: 'notifications' })).state; } catch { query = 'error'; }
  const gl = (() => {
    try { const g = document.createElement('canvas').getContext('webgl'); const d = g.getExtension('WEBGL_debug_renderer_info'); return g.getParameter(d ? d.UNMASKED_RENDERER_WEBGL : g.RENDERER); } catch { return null; }
  })();
  return {
    ua_says_headless: /Headless/.test(navigator.userAgent),
    webdriver: navigator.webdriver,
    plugins: navigator.plugins.length,
    notification_mismatch: typeof Notification !== 'undefined' && Notification.permission === 'denied' && query === 'prompt',
    outer_window_zero: outerWidth === 0 || outerHeight === 0,
    screen: screen.width + 'x' + screen.height,
    webgl_renderer: gl,
    cores: navigator.hardwareConcurrency,
    memory_gb: navigator.deviceMemory ?? null,
    languages: navigator.languages.join(','),
    chrome_object: typeof window.chrome === 'object' && window.chrome !== null,
  };
})()`;

async function inspect({ label, engine, type, body, prefix = '' }) {
  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, ...body }),
  });
  if (!res.ok) return { label, error: `launch ${res.status} ${await res.text()}` };
  const { wsUrl, weight } = await res.json();
  const browser = await type.connect(wsUrl, { headers: { 'x-api-key': KEY } });
  try {
    const page = await browser.newPage();
    await page.goto('https://example.com/', { timeout: 60000 });
    const signals = await page.evaluate(prefix + SIGNALS);
    const tells = ['ua_says_headless', 'webdriver', 'notification_mismatch', 'outer_window_zero'].filter((k) => signals[k]);
    return { label, threads: weight, ...signals, tells: tells.length ? tells.join(', ') : 'none' };
  } finally {
    await browser.close();
  }
}

console.log(JSON.stringify(await Promise.all(TARGETS.map(inspect)), null, 2));

What we got

BrowserThreadsTells firedHeadless in UAwebdriverPluginsNotification contradictionScreenWebGL rendererCoresMemory (GB)window.chrome
Chromium, headless1ua_says_headless, notification_mismatchyesno0yes1280x720ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)6432no
Chromium, headful2nonenono5no1280x720ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)6432yes
Patchright, headful2nonenono5no1280x720—6432yes
Camoufox, headful2nonenono5no2560x1440ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar16—no

From the Node.js run on 2026-10-04. 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)
[
  {
    "label": "Chromium, headless",
    "threads": 1,
    "ua_says_headless": true,
    "webdriver": false,
    "plugins": 0,
    "notification_mismatch": true,
    "outer_window_zero": false,
    "screen": "1280x720",
    "webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
    "cores": 64,
    "memory_gb": 32,
    "languages": "en-US",
    "chrome_object": false,
    "tells": "ua_says_headless, notification_mismatch"
  },
  {
    "label": "Chromium, headful",
    "threads": 2,
    "ua_says_headless": false,
    "webdriver": false,
    "plugins": 5,
    "notification_mismatch": false,
    "outer_window_zero": false,
    "screen": "1280x720",
    "webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
    "cores": 64,
    "memory_gb": 32,
    "languages": "en-US",
    "chrome_object": true,
    "tells": "none"
  },
  {
    "label": "Patchright, headful",
    "threads": 2,
    "ua_says_headless": false,
    "webdriver": false,
    "plugins": 5,
    "notification_mismatch": false,
    "outer_window_zero": false,
    "screen": "1280x720",
    "webgl_renderer": null,
    "cores": 64,
    "memory_gb": 32,
    "languages": "en-US",
    "chrome_object": true,
    "tells": "none"
  },
  {
    "label": "Camoufox, headful",
    "threads": 2,
    "ua_says_headless": false,
    "webdriver": false,
    "plugins": 5,
    "notification_mismatch": false,
    "outer_window_zero": false,
    "screen": "2560x1440",
    "webgl_renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar",
    "cores": 16,
    "memory_gb": null,
    "languages": "en-PT,en",
    "chrome_object": false,
    "tells": "none"
  }
]

Takeaways