Headless browser detection is how a website tells an automated browser, such as Chrome driven by Puppeteer or Playwright, from a person's browser. Since Chrome's new headless mode the browser itself looks normal, so detection now targets the automation driver and the server environment instead.
TL;DR
- New headless Chrome is the same binary as headed Chrome. It became the default meaning of
--headlessin Chrome 128 and the old mode was removed from the Chrome binary in Chrome 132 (January 2025). The old mode survives only as the separatechrome-headless-shellbinary, which still announcesHeadlessChromein its user agent, and some drivers still download it by default. - What remains detectable is the driving, not the headlessness:
navigator.webdriver, side effects of the DevTools Protocol, and the software GPU in a server environment. - The best known protocol tell is
Runtime.enable: enabling it makes the page's console serialization observable, which is why modern stealth tools avoid that command entirely. A newer one is isolated-world detection: init scripts injected through CDP run in a separate world, and a page can tell. - A May 2026 benchmark of seven tools against 31 live targets found that the driver choice mattered more than any patch: a plain CDP driver on system Chrome had zero hard blocks; patched Playwright forks still failed the hardest gates.
- Patching properties from JavaScript leaves its own traces (modified
toString, wrong property descriptors). Patching at the C++ level does not. - A real user's browser with a real profile scores differently from any headless setup, which is why browser-extension scrapers occupy a distinct category.
Where the tells live
flowchart TB
accTitle: Where headless browser tells come from
accDescr: The automation driver and the server environment both leak signals into page JavaScript, which the vendor script reads.
D[Automation driver<br/>Puppeteer, Playwright, nodriver] -->|CDP commands| CH[Chrome process]
CH --> PG[Page JavaScript]
D -. "Runtime.enable → console serialization leak" .-> PG
D -. "init scripts in isolated world" .-> PG
D -. "--enable-automation → navigator.webdriver" .-> PG
ENV[Environment<br/>no GPU, server fonts, UTC, datacenter IP] --> CH
ENV -. "SwiftShader renderer, font list, timezone" .-> PG
PG --> V[vendor script reads all of it]
class D violet
class ENV amber
class CH,PG soft
class V bad
linkStyle 2,3,4,6 stroke:#ef4444,stroke-width:2px
Solid arrows are the normal path. Dotted arrows are side effects a page can observe. The first three come from the driver and can be fixed in tooling. The last comes from the machine and cannot.
What changed with new headless
For years, headless Chrome was a separate implementation with gaps: no chrome object, an empty plugin list, a user agent containing "HeadlessChrome", and outerWidth of zero. Detection scripts checked for these, and stealth plugins patched them one by one.
Chrome 112 shipped a new headless mode that runs the full browser without a window. Chrome 128 made it what --headless means, and Chrome 132 removed the old implementation from the binary; --headless=old now prints an error. The old mode lives on as chrome-headless-shell, a separate download that Puppeteer and some CI images still use for speed. If your automation reports HeadlessChrome in the user agent in 2026, you are running the shell, not Chrome.
With the real binary the gaps closed. A new headless Chrome with a GPU and a normal profile passes the simple property checks without any plugin. The detection surface moved to three places.
Tell 1: automation flags
When Chrome is launched by a WebDriver client or with --enable-automation, navigator.webdriver is true. This is the specified behavior and it is the first thing every detector reads. Puppeteer and Playwright set the flag by default; both can be told not to, and tools such as nodriver and undetected-chromedriver never set it.
Related: the --headless switch still changes a few things, such as the default window size and the absence of a real display. Launch with a realistic --window-size and, on Linux servers, under a virtual display or with a GPU, or the geometry and renderer will not match the claimed platform.
Tell 2: DevTools Protocol side effects
Puppeteer, Playwright and Selenium's CDP mode drive the page through the Chrome DevTools Protocol. Some CDP commands have effects a page can observe. The best known is Runtime.enable, which the clients send to receive console and exception events.
Once enabled, Chrome serializes objects passed to console.log for the inspector. A page can log an object with a getter and check whether the getter ran. If it did, something is listening, and that something is DevTools.
Error stack traces are another case: with an inspector attached, certain stack formats and Error.prepareStackTrace behaviors differ. Detectors measure these.
The response from tooling was to stop calling Runtime.enable. nodriver never did; Patchright (a Playwright fork, still actively released, version 1.63 in September 2026) and the older rebrowser-patches project removed it, at the cost of some automation features that depended on it. rebrowser-patches has had no code commits since September 2024 and in the May 2026 benchmark its block set was identical to unpatched Playwright, so treat it as historical.
The next protocol tell is isolated worlds. Playwright and Puppeteer run init scripts and some evaluations in an isolated JavaScript world so that page code cannot see them. A page can detect that: it defines a Symbol-keyed property on window and checks, from a context it controls, whether code that should have seen it did.
There is no clean fix in stock Playwright as of 2026, which is another reason drivers that stay in the main world score better. Related: there is a brief window after navigation in which a page script can read navigator.webdriver before an init script has patched it, so timing-based checks catch property patches that static checks miss.
Tell 3: the environment
A datacenter machine has no real GPU, so WebGL reports SwiftShader or a llvmpipe renderer. It has a server font set, often only DejaVu. Its timezone is UTC and its IP is in a hosting ASN.
None of these are "headless" tells strictly, but they appear together with headless browsers so often that vendors treat the cluster as one signature. Fixing the driver while leaving the environment untouched does not help.
JavaScript patches leave fingerprints
The first generation of stealth plugins overwrote properties from an injected script: redefine navigator.webdriver, add fake plugins, override WebGLRenderingContext.getParameter. Each override is detectable. Function.prototype.toString on a patched method returns JavaScript source instead of [native code] unless toString is also patched, and then the patch of toString is detectable through its own descriptor.
Prototype chains gain extra properties. Detectors enumerate these.
Second generation tools apply changes inside the browser binary or through CDP emulation commands that Chrome itself supports, so there is nothing for page JavaScript to see. Camoufox does this for Firefox at the C++ level. The stealth tooling post compares them.
The targets keep moving on their own. The structure of window.chrome changed in 2024 and again in early 2026, so a faked object that matched last year's shape is now a tell. Fake plugins that are plain objects fail instanceof Plugin. And the Chromium team itself patches the most publicized detection vectors in CDP, which means a tool that was clean in one release can regress or improve in the next without changing a line.
Demonstration: score a browser from a page
Save this as score.html, serve it locally, and open it in headed Chrome, then in Puppeteer with defaults, then in Puppeteer with --disable-blink-features=AutomationControlled and no Runtime.enable (the rebrowser-puppeteer package exposes that).
<script>
const checks = {};
checks.webdriver = navigator.webdriver === true;
checks.softwareGpu = (() => {
const g = document.createElement('canvas').getContext('webgl');
const d = g && g.getExtension('WEBGL_debug_renderer_info');
const r = d ? g.getParameter(d.UNMASKED_RENDERER_WEBGL) : '';
return /swiftshader|llvmpipe|software/i.test(r);
})();
checks.zeroOuter = outerWidth === 0 || outerHeight === 0;
checks.patchedToString = /webdriver|plugins/.test(
Function.prototype.toString.call(Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver').get));
checks.permissionMismatch = false;
navigator.permissions.query({ name: 'notifications' }).then(p => {
checks.permissionMismatch = Notification.permission === 'denied' && p.state === 'prompt';
});
checks.runtimeEnable = false;
const probe = { get leak() { checks.runtimeEnable = true; return 1; } };
console.log(probe);
setTimeout(() => {
const score = Object.values(checks).filter(Boolean).length;
document.body.textContent = JSON.stringify({ score, ...checks }, null, 2);
}, 500);
</script>
Expected results:
| Browser | score | which checks |
|---|---|---|
| Headed Chrome, your profile | 0 | none |
| Puppeteer defaults on a laptop | 2 | webdriver, runtimeEnable |
| Puppeteer defaults on a Linux server | 3 to 4 | plus softwareGpu, sometimes zeroOuter |
| rebrowser-puppeteer with automation flag off | 0 to 1 | softwareGpu on a server only |
The runtimeEnable check is the interesting row. It is invisible to property inspection and survives every JavaScript stealth patch. Only not sending the command fixes it.
Read more: the full list of classic tells and their status
| Tell | Old headless | New headless | Notes |
|---|---|---|---|
| "HeadlessChrome" in user agent | yes (chrome-headless-shell) |
no | old mode removed from Chrome in 132 |
navigator.webdriver |
with automation | with automation | flag controlled, not headless controlled |
Missing window.chrome |
yes | no | |
Empty navigator.plugins |
yes | no | Chrome now reports a fixed PDF plugin set everywhere |
outerWidth === 0 |
yes | depends | set a window size |
| Permissions and Notification mismatch | yes | mostly fixed | |
| Software WebGL renderer | environment | environment | GPU or a realistic emulated string |
Missing Accept-Language |
yes | no | set it anyway |
Runtime.enable console serialization |
driver | driver | avoid the command |
CDP Page.addScriptToEvaluateOnNewDocument artifacts |
driver | driver | injected scripts appear in stack traces if careless |
Mouse events with isTrusted: false |
script | script | use CDP input, not DOM dispatchEvent |
| Isolated-world init scripts (Symbol probe) | driver | driver | no clean fix in stock Playwright, 2026 |
window.chrome shape drift |
fake | fake | shape changed 2024 and early 2026 |
| UA without matching Client Hints | config | config | stopped passing in 2024 to 2025 |
Read more: why a user's own browser is a different category
A browser extension that scrapes inside the user's logged-in Chrome has a real GPU, real fonts, a real profile history, real cookies, the user's own connection, and no DevTools session. To the layers discussed here it looks like a person's browser, because it is one. The tradeoffs are different: throughput is limited by one machine and one IP, the user is accountable for the account used, and the extension's own content script must avoid changing the page in ways a vendor script could notice. That category is where in-browser tools such as ScrapePass sit, and it is why they don't use the stealth machinery above. Sites can still restrict access or limit requests from a real browser, so rate limits, terms and permissions apply as usual.
Frequently asked questions
Can websites detect headless Chrome?
They can detect most automated sessions, though rarely from headless mode alone. New headless Chrome is the same binary as normal Chrome. What gives it away is the driver, through navigator.webdriver, DevTools Protocol side effects and injected scripts, and a server environment with no GPU and a datacenter IP.
What is the Runtime.enable leak?
Automation libraries send the DevTools command Runtime.enable to receive console events. Once it is on, Chrome serializes objects passed to console.log, and a page can notice because a getter on the logged object runs. Drivers such as nodriver and Patchright avoid the command to remove this tell.
Does puppeteer-extra-plugin-stealth still work?
Not against modern detection. It patches properties from JavaScript, and those patches are detectable through function toString results, property descriptors and timing. Its maintainers deprecated it in February 2025.
Is HeadlessChrome still in the user agent?
Only in chrome-headless-shell, the separate binary that keeps the old headless mode. Chrome removed the old mode from the main binary in version 132, and new headless Chrome reports a normal user agent.
Sources, checked October 2026
- Removing --headless=old from Chrome (Chrome for Developers, October 2024)
- Headless Browser Detection in 2026: What Still Trips Up Playwright (DEV)
- Anti-Detect Browser Benchmark 2026: 7 Tools, 651 Verdicts (Ian Paterson, May 2026, updated August 2026)
- Patchright vs Playwright: what it patches and what it leaves (ScaleBrowser)
- Nodriver tutorial, 2026 (ScrapingBee)