Skip to content
Anti-bot systems

Bot detection signals: what headless Chrome exposes

We loaded one local page 80 times in 16 Chrome, Playwright and Puppeteer setups and recorded what each exposed to bot detection.

The finding

13 of 15checks on which Playwright's default headless launch differed from an ordinary Chrome window

Playwright 1.62.1's default headless launch differed from an ordinary Chrome window on 13 of 15 checks, Puppeteer 23.4.0 in headless: 'shell' mode on 14, and Playwright attached to a hand-started Chrome through a fixed debugging port on 0.

Measured by us Sample 16 browser configurations x 5 runs = 80 page loads of one local page, all 80 completed: 11 configurations plus 5 single-switch isolation runs How it was measured Download the data CSV, JSON

On this page 14 sections
  1. Method
  2. How far each setup was from a normal Chrome window
  3. Which checks caught what
  4. What sets navigator.webdriver
  5. User-Agent and client hints
  6. Five properties where only the headless shell differed
  7. Window and screen geometry
  8. What the common evasion flags changed
  9. The DevTools console check only fired on Chrome 129
  10. Two differences we did not explain
  11. Reading the results
  12. Reproducing it
  13. Limits
  14. Download the data

Playwright’s default headless launch, which runs chrome-headless-shell, differed from an ordinary Chrome window on 13 of the 15 checks we ran. Puppeteer in headless: 'shell' mode differed on 14. Playwright attached over the DevTools protocol to a Chrome we had started ourselves differed on none. The library is not what separates these results. The browser build it launches and the switches it passes are.

Those figures come from one page we wrote, served from 127.0.0.1 and loaded by 16 browser configurations five times each: 80 page loads on 21 October 2026, and all 80 completed. The page recorded what a script can read about the browser running it. The server recorded the request headers it received. No third-party site was involved, so this is a map of what a headless browser exposes to bot detection, not a test of any vendor’s product.

This study measures what is visible, not what any site does with it. No detection vendor was tested, and nothing here shows that a given product reads a given signal or how heavily it weighs one.

Method

A Node 24.2.0 http server listened on a random port on 127.0.0.1 and served a single HTML page at /probe. A script on that page read the properties listed below and sent them back in a POST to the same server. The server logged req.rawHeaders for the navigation request, which keeps header names in the order and case they arrived.

Every run started a fresh browser process with a fresh profile and loaded the page once. Five runs per configuration, sixteen configurations, 80 page loads. The machine was a Mac with an Apple M5 and 10 cores, running macOS 26.6.2. The control was Google Chrome 155.0.8059.85 started as a normal process with a new profile and --no-first-run --no-default-browser-check, no debugging switch and no automation library attached. It is the baseline every other row is compared against.

Configuration Driver Browser build Mode Runs completed
Chrome 155, headed, no automation (control) none Google Chrome 155.0.8059.85 headed 5 of 5
Chrome 155 –headless, no library none Google Chrome 155.0.8059.85 new headless 5 of 5
Playwright, defaults (headless shell 148) Playwright 1.62.1 chrome-headless-shell 148.0.7778.96 old headless (shell) 5 of 5
Playwright, new headless (CfT 148) Playwright 1.62.1 Chrome for Testing 148.0.7778.96 new headless 5 of 5
Playwright, channel 'chrome' (Chrome 155) Playwright 1.62.1 Google Chrome 155.0.8059.85 new headless 5 of 5
Playwright, headless: false (CfT 148) Playwright 1.62.1 Chrome for Testing 148.0.7778.96 headed 5 of 5
Playwright, defaults + common flags Playwright 1.62.1 chrome-headless-shell 148.0.7778.96 old headless (shell) 5 of 5
Puppeteer, defaults (CfT 129) Puppeteer 23.4.0 Chrome for Testing 129.0.6668.58 new headless 5 of 5
Puppeteer, headless: 'shell' (129) Puppeteer 23.4.0 chrome-headless-shell 129.0.6668.58 old headless (shell) 5 of 5
Puppeteer, Chrome 155 as executablePath Puppeteer 23.4.0 Google Chrome 155.0.8059.85 new headless 5 of 5
Chrome 155 by hand + Playwright connectOverCDP Playwright 1.62.1 (attach) Google Chrome 155.0.8059.85 headed 5 of 5

Five more configurations isolate single launch switches on Chrome 155 with no library attached. They appear in the navigator.webdriver section and in the downloadable data.

One deviation from a pure default needs stating. Playwright 1.62.1 pins Chromium build 1234 (151.0.7922.34), which was not installed on the test machine, and we did not download it. Its cache held build 1223 (148.0.7778.96) of both chrome-headless-shell and Chrome for Testing, so those binaries were passed as executablePath. Every other launch option stayed at its default. Puppeteer 23.4.0 ran its own pinned 129.0.6668.58 build, and once with the installed Chrome 155 so the library and the Chrome version can be told apart.

The page read these properties:

  • navigator.webdriver, the user agent, and navigator.userAgentData brands plus its high-entropy values
  • plugins, MIME types and languages
  • the unmasked WebGL vendor and renderer
  • Notification.permission beside navigator.permissions.query() for six permissions
  • window.chrome and its keys
  • window and screen geometry, focus, visibility, and the hover and pointer media queries
  • media device counts, speech synthesis voices and navigator.connection
  • a SHA-256 hash of a small 2D canvas drawing, and the list of global names on window
  • whether console.debug read a getter placed on an Error’s stack

Those raw fields became 15 named checks. Each check asks one question, such as “is navigator.plugins empty?”, and a configuration counts as differing when its answer is not the control’s. Results were stable. Apart from the run number, only one of the 61 columns in the published CSV ever changed between the five runs of a configuration. That was navigator.connection.rtt, which Chrome rounds to a multiple of 25 ms and which read 50, 100 or 150 in the control. Every other value was identical in all five runs.

How far each setup was from a normal Chrome window

Of 15 page and header checks, how many differed from an ordinary Chrome window. 5 runs per configuration, 21 October 2026. Drawn from the table below.
Of 15 page and header checks, how many differed from an ordinary Chrome window. 5 runs per configuration, 21 October 2026.. Bar chart of Checks that differed from the control by Configuration.11 rows, drawn from the table that follows this chart.Chrome 155, headed, no automation(control)Chrome 155, headed, no automation (control): 00Chrome 155 –headless, no libraryChrome 155 –headless, no library: 22Playwright, defaults (headless shell148)Playwright, defaults (headless shell 148): 1313Playwright, new headless (CfT 148)Playwright, new headless (CfT 148): 55Playwright, channel 'chrome' (Chrome155)Playwright, channel 'chrome' (Chrome 155): 44Playwright, headless: false (CfT148)Playwright, headless: false (CfT 148): 33Playwright, defaults + common flagsPlaywright, defaults + common flags: 1111Puppeteer, defaults (CfT 129)Puppeteer, defaults (CfT 129): 55Puppeteer, headless: 'shell' (129)Puppeteer, headless: 'shell' (129): 1414Puppeteer, Chrome 155 asexecutablePathPuppeteer, Chrome 155 as executablePath: 44Chrome 155 by hand + PlaywrightconnectOverCDPChrome 155 by hand + Playwright connectOverCDP: 0002468101214Of 15 page and header checks, how many differed from an ordinary Chrome window. 5 runs per configuration, 21 October 2026.. Bar chart of Checks that differed from the control by Configuration.11 rows, drawn from the table that follows this chart.Chrome 155, headed, noautomation (control)Chrome 155, headed, no automation (control): 00Chrome 155 –headless, nolibraryChrome 155 –headless, no library: 22Playwright, defaults(headless shell 148)Playwright, defaults (headless shell 148): 1313Playwright, new headless(CfT 148)Playwright, new headless (CfT 148): 55Playwright, channel'chrome' (Chrome 155)Playwright, channel 'chrome' (Chrome 155): 44Playwright, headless:false (CfT 148)Playwright, headless: false (CfT 148): 33Playwright, defaults +common flagsPlaywright, defaults + common flags: 1111Puppeteer, defaults (CfT129)Puppeteer, defaults (CfT 129): 55Puppeteer, headless:'shell' (129)Puppeteer, headless: 'shell' (129): 1414Puppeteer, Chrome 155 asexecutablePathPuppeteer, Chrome 155 as executablePath: 44Chrome 155 by hand +Playwright connectOver…Chrome 155 by hand + Playwright connectOverCDP: 00051015
Configuration Checks that differed from the control
Chrome 155, headed, no automation (control) 0
Chrome 155 –headless, no library 2
Playwright, defaults (headless shell 148) 13
Playwright, new headless (CfT 148) 5
Playwright, channel 'chrome' (Chrome 155) 4
Playwright, headless: false (CfT 148) 3
Playwright, defaults + common flags 11
Puppeteer, defaults (CfT 129) 5
Puppeteer, headless: 'shell' (129) 14
Puppeteer, Chrome 155 as executablePath 4
Chrome 155 by hand + Playwright connectOverCDP 0

Three groups fall out of the chart. The old headless shell sits at 13 or 14 with library defaults, and at 11 with the common evasion flags. Real Chrome builds driven by either library sit between 3 and 5. The two configurations that never set an automation switch, plain --headless and the hand-started Chrome that Playwright reached through a fixed debugging port, sit at 2 and 0.

The first group is large because chrome-headless-shell is a separate, older browser implementation. Chrome’s own documentation says that since Chrome 132.0.6793.0 the old headless mode exists only as that standalone binary. Playwright uses it by default for headless runs, and Puppeteer uses it when asked for headless: 'shell'. Most of what follows is about the gap between that binary and a real Chrome.

Which checks caught what

How many of the 10 automated or headless configurations failed each check. 5 runs each, 21 October 2026. Drawn from the table below.
How many of the 10 automated or headless configurations failed each check. 5 runs each, 21 October 2026.. Bar chart of Configurations that differed (of 10) by Check.15 rows, drawn from the table that follows this chart.navigator.webdriver is truenavigator.webdriver is true: 77User-Agent contains HeadlessChromeUser-Agent contains HeadlessChrome: 77User-Agent carries a full, unreducedversionUser-Agent carries a full, unreduced version: 33Client-hint brands includeHeadlessChromeClient-hint brands include HeadlessChrome: 33No Accept-Language headerNo Accept-Language header: 22Accept-Language sent out of thecontrol positionAccept-Language sent out of the control position: 22navigator.languages differs from thecontrolnavigator.languages differs from the control: 66navigator.plugins is emptynavigator.plugins is empty: 33WebGL missing or SwiftShaderWebGL missing or SwiftShader: 33Notification.permissioncontradicts the Permissions APINotification.permission contradicts the Permissions API: 33window.chrome is undefinedwindow.chrome is undefined: 33outerHeight minus innerHeight is 0or negativeouterHeight minus innerHeight is 0 or negative: 66screen size is not the real displayscreen size is not the real display: 88navigator.connection.rtt is 0navigator.connection.rtt is 0: 33console.debug read the Error stackgetterconsole.debug read the Error stack getter: 2202468How many of the 10 automated or headless configurations failed each check. 5 runs each, 21 October 2026.. Bar chart of Configurations that differed (of 10) by Check.15 rows, drawn from the table that follows this chart.navigator.webdriver istruenavigator.webdriver is true: 77User-Agent containsHeadlessChromeUser-Agent contains HeadlessChrome: 77User-Agent carries afull, unreduced versionUser-Agent carries a full, unreduced version: 33Client-hint brandsinclude HeadlessChromeClient-hint brands include HeadlessChrome: 33No Accept-LanguageheaderNo Accept-Language header: 22Accept-Language sent outof the control positionAccept-Language sent out of the control position: 22navigator.languagesdiffers from the controlnavigator.languages differs from the control: 66navigator.plugins isemptynavigator.plugins is empty: 33WebGL missing orSwiftShaderWebGL missing or SwiftShader: 33Notification.permissioncontradicts the Permis…Notification.permission contradicts the Permissions API: 33window.chrome isundefinedwindow.chrome is undefined: 33outerHeight minusinnerHeight is 0 or neg…outerHeight minus innerHeight is 0 or negative: 66screen size is not thereal displayscreen size is not the real display: 88navigator.connection.r…is 0navigator.connection.rtt is 0: 33console.debug read theError stack getterconsole.debug read the Error stack getter: 2202468
Check Configurations that differed (of 10) Which
navigator.webdriver is true 7 Playwright, defaults (headless shell 148), Playwright, new headless (CfT 148), Playwright, channel 'chrome' (Chrome 155), Playwright, headless: false (CfT 148), Puppeteer, defaults (CfT 129), Puppeteer, headless: 'shell' (129), Puppeteer, Chrome 155 as executablePath
User-Agent contains HeadlessChrome 7 Chrome 155 –headless, no library, Playwright, defaults (headless shell 148), Playwright, new headless (CfT 148), Playwright, channel 'chrome' (Chrome 155), Puppeteer, defaults (CfT 129), Puppeteer, headless: 'shell' (129), Puppeteer, Chrome 155 as executablePath
User-Agent carries a full, unreduced version 3 Playwright, defaults (headless shell 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129)
Client-hint brands include HeadlessChrome 3 Playwright, defaults (headless shell 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129)
No Accept-Language header 2 Playwright, defaults (headless shell 148), Puppeteer, headless: 'shell' (129)
Accept-Language sent out of the control position 2 Playwright, defaults + common flags, Puppeteer, defaults (CfT 129)
navigator.languages differs from the control 6 Playwright, defaults (headless shell 148), Playwright, new headless (CfT 148), Playwright, headless: false (CfT 148), Playwright, defaults + common flags, Puppeteer, defaults (CfT 129), Puppeteer, headless: 'shell' (129)
navigator.plugins is empty 3 Playwright, defaults (headless shell 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129)
WebGL missing or SwiftShader 3 Playwright, defaults (headless shell 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129)
Notification.permission contradicts the Permissions API 3 Playwright, defaults (headless shell 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129)
window.chrome is undefined 3 Playwright, defaults (headless shell 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129)
outerHeight minus innerHeight is 0 or negative 6 Playwright, defaults (headless shell 148), Playwright, new headless (CfT 148), Playwright, channel 'chrome' (Chrome 155), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129), Puppeteer, Chrome 155 as executablePath
screen size is not the real display 8 Chrome 155 –headless, no library, Playwright, defaults (headless shell 148), Playwright, new headless (CfT 148), Playwright, channel 'chrome' (Chrome 155), Playwright, headless: false (CfT 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129), Puppeteer, Chrome 155 as executablePath
navigator.connection.rtt is 0 3 Playwright, defaults (headless shell 148), Playwright, defaults + common flags, Puppeteer, headless: 'shell' (129)
console.debug read the Error stack getter 2 Puppeteer, defaults (CfT 129), Puppeteer, headless: 'shell' (129)

The broad checks were the screen size, which caught 8 of the 10, and the HeadlessChrome token and navigator.webdriver, at 7 each. Screen size and the token tell you little on their own. Real screens vary, and the token is a string anyone can replace. The narrow checks are more interesting. Seven of them caught exactly the three headless-shell configurations and nothing else: the unreduced version, the HeadlessChrome brand, empty plugins, missing or software WebGL, the permission contradiction, an undefined window.chrome and a zero round-trip time. Seven properties that agree with each other are harder to explain away than one.

What sets navigator.webdriver

The W3C WebDriver specification has navigator.webdriver return true when the browser’s webdriver-active flag is set, meaning it is under remote control. MDN says Chrome sets it when --enable-automation or --headless is used, or when --remote-debugging-port is given port 0. We tested each switch alone.

Chrome launched with navigator.webdriver Runs true
--headless only false 0 of 5
--headless --enable-automation true 5 of 5
--headless --remote-debugging-port=0 true 5 of 5
--headless --remote-debugging-port=9xxx false 0 of 5
--headless --remote-debugging-pipe true 5 of 5
chrome-headless-shell 148, URL only true 5 of 5

Our result for --headless alone does not match MDN. On Chrome 155 in new headless mode it left navigator.webdriver false in all five runs. The Chromium source file content/child/runtime_features.cc does list switches::kHeadless beside kEnableAutomation and kRemoteDebuggingPipe as triggers, so the switch is wired up in code. We did not trace why it had no effect here. The old headless shell, launched with nothing but a URL, did report true.

The same source explains the port result in a comment: port 0 is how ChromeDriver launches the browser, while a specific port number “is more likely for attaching a debugger”, so Chrome leaves the flag unset. Our fixed-port run agreed.

That maps directly onto the libraries. Puppeteer 23.4.0 launched Chrome with both --enable-automation and --remote-debugging-port=0, read back from the browser through Browser.getBrowserCommandLine. Playwright 1.62.1 does not pass --enable-automation at all. When we asked its browsers for their command line, Chrome refused with this exact message:

Protocol error (Browser.getBrowserCommandLine): Command line not returned because --enable-automation not set.

Playwright connects over --remote-debugging-pipe instead, and the pipe sets the flag on its own, so every Playwright launch still reported true. Adding --disable-blink-features=AutomationControlled turned it off. So did attaching Playwright with connectOverCDP to a Chrome we had started with --remote-debugging-port=9xxx: five runs, all false.

User-Agent and client hints

Configuration User-Agent product token navigator.userAgentData brands Accept-Language header
Chrome 155, headed, no automation (control) Chrome/155.0.0.0 Google Chrome;v=155, Chromium;v=155, Not(A:Brand;v=24 en-GB,en-US;q=0.9,en;q=0.8
Chrome 155 –headless, no library HeadlessChrome/155.0.0.0 Google Chrome;v=155, Chromium;v=155, Not(A:Brand;v=24 en-GB,en-US;q=0.9,en;q=0.8
Playwright, defaults (headless shell 148) HeadlessChrome/148.0.7778.96 Chromium;v=148, HeadlessChrome;v=148, Not/A)Brand;v=99 —
Playwright, new headless (CfT 148) HeadlessChrome/148.0.0.0 Not/A)Brand;v=99, Chromium;v=148 en-GB,en;q=0.9
Playwright, channel 'chrome' (Chrome 155) HeadlessChrome/155.0.0.0 Google Chrome;v=155, Chromium;v=155, Not(A:Brand;v=24 en-GB,en-US;q=0.9,en;q=0.8
Playwright, headless: false (CfT 148) Chrome/148.0.0.0 Not/A)Brand;v=99, Chromium;v=148 en-GB,en;q=0.9
Playwright, defaults + common flags Chrome/148.0.7778.96 Chromium;v=148, HeadlessChrome;v=148, Not/A)Brand;v=99 en-US
Puppeteer, defaults (CfT 129) HeadlessChrome/129.0.0.0 Chromium;v=129, Not=A?Brand;v=8 en-GB,en;q=0.9
Puppeteer, headless: 'shell' (129) HeadlessChrome/129.0.6668.58 HeadlessChrome;v=129, Not=A?Brand;v=8, Chromium;v=129 —
Puppeteer, Chrome 155 as executablePath HeadlessChrome/155.0.0.0 Google Chrome;v=155, Chromium;v=155, Not(A:Brand;v=24 en-GB,en-US;q=0.9,en;q=0.8
Chrome 155 by hand + Playwright connectOverCDP Chrome/155.0.0.0 Google Chrome;v=155, Chromium;v=155, Not(A:Brand;v=24 en-GB,en-US;q=0.9,en;q=0.8

New headless mode keeps the HeadlessChrome token in the user agent string but does not add a HeadlessChrome brand to the client hints. In Chrome 155, both headless and headed reported "Google Chrome";v="155", "Chromium";v="155", "Not(A:Brand";v="24" in sec-ch-ua. The headless shell is the opposite case: it puts HeadlessChrome in the brand list too, and its user agent carries the full build number, 148.0.7778.96. Chrome’s User-Agent Reduction fixed desktop Chrome at Chrome/<major>.0.0.0, and every real Chrome build we ran followed that format, so a full four-part version in a Chrome-claiming user agent is itself out of place.

Chrome for Testing reports only Chromium and a GREASE brand, with no Google Chrome entry. That is a property of the build, not of automation, and ordinary Chromium users will show the same thing. It still separates a Playwright or Puppeteer download from an installed Chrome.

The common fix produced a request that disagrees with itself. Rewriting the user agent left the client hints alone, so the server received a user agent that says Chrome and a brand list that says HeadlessChrome, on consecutive lines of the same request. This is the navigation exactly as captured, with only the port removed:

Host: 127.0.0.1:<port>
Connection: keep-alive
sec-ch-ua: "Chromium";v="148", "HeadlessChrome";v="148", "Not/A)Brand";v="99"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.7778.96 Safari/537.36
Accept-Language: en-US
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br, zstd

Note where Accept-Language sits. The control sent it last, as the 14th of 14 headers. Setting locale in Playwright moved it to eighth, straight after User-Agent. Without a locale, the headless shell sent no Accept-Language header at all, 13 headers in total. Puppeteer on Chrome 129 sent it sixth, but Puppeteer on Chrome 155 sent it last, so that one is a Chrome-version difference rather than a library one.

Chrome 155 sent 14 navigation headers here. Our earlier study of default request headers counted 11 from a Chromium-based browser. The difference is the three sec-ch-ua client hints, which Chrome 155 sent on loopback and that capture did not contain.

Five properties where only the headless shell differed

Configuration navigator.plugins WebGL renderer Notification.permission / permissions.query typeof window.chrome connection.rtt seen (ms)
Chrome 155, headed, no automation (control) 5 Apple M5 via ANGLE Metal default / prompt object 50, 100, 150
Chrome 155 –headless, no library 5 Apple M5 via ANGLE Metal default / prompt object 50, 100, 150
Playwright, defaults (headless shell 148) 0 SwiftShader (software) denied / prompt undefined 0
Playwright, new headless (CfT 148) 5 Apple M5 via ANGLE Metal default / prompt object 50
Playwright, channel 'chrome' (Chrome 155) 5 Apple M5 via ANGLE Metal default / prompt object 50
Playwright, headless: false (CfT 148) 5 Apple M5 via ANGLE Metal default / prompt object 50
Playwright, defaults + common flags 0 SwiftShader (software) denied / prompt undefined 0
Puppeteer, defaults (CfT 129) 5 Apple M5 via ANGLE Metal default / prompt object 100, 150
Puppeteer, headless: 'shell' (129) 0 no WebGL context denied / prompt undefined 0
Puppeteer, Chrome 155 as executablePath 5 Apple M5 via ANGLE Metal default / prompt object 50, 100, 150
Chrome 155 by hand + Playwright connectOverCDP 5 Apple M5 via ANGLE Metal default / prompt object 50, 100

Every row in that table that differs from the control is a headless-shell row. The shell reported no plugins and no MIME types, and navigator.pdfViewerEnabled was false. Under Puppeteer it had no WebGL context at all. Under Playwright, whose launch code adds --enable-unsafe-swiftshader, WebGL existed but the unmasked renderer read ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver), a software rasteriser, on a machine whose real GPU every other configuration reported as the Apple M5.

The permission result is the clearest contradiction. The WHATWG Notifications standard says that when the Permissions API state is prompt, Notification.permission must return default. The shell returned denied from one and prompt from the other in all 20 of its runs across four configurations, including the bare launch with no library. Every real Chrome build, headless or not, returned the matching pair.

window.chrome was undefined in the shell and an object with app, csi and loadTimes everywhere else, the control included. chrome.runtime was absent in every configuration, the control too, so its absence is not a tell on an ordinary page. And navigator.connection.rtt read exactly 0 in all 20 shell runs, where the other 60 runs read between 50 and 150.

Window and screen geometry

Height of the browser UI the page can infer: outerHeight minus innerHeight. Identical in all 5 runs of each configuration. Drawn from the table below.
Height of the browser UI the page can infer: outerHeight minus innerHeight. Identical in all 5 runs of each configuration.. Bar chart of outerHeight minus innerHeight by Configuration.12 rows, drawn from the table that follows this chart.Chrome 155, headed, no automation(control)Chrome 155, headed, no automation (control): 8787Chrome 155 –headless, no libraryChrome 155 –headless, no library: 8787Playwright, defaults (headless shell148)Playwright, defaults (headless shell 148): 00Playwright, new headless (CfT 148)Playwright, new headless (CfT 148): 00Playwright, channel 'chrome' (Chrome155)Playwright, channel 'chrome' (Chrome 155): 00Playwright, headless: false (CfT148)Playwright, headless: false (CfT 148): 8080Playwright, defaults + common flagsPlaywright, defaults + common flags: 00Puppeteer, defaults (CfT 129)Puppeteer, defaults (CfT 129): 224224Puppeteer, headless: 'shell' (129)Puppeteer, headless: 'shell' (129): 00Puppeteer, Chrome 155 asexecutablePathPuppeteer, Chrome 155 as executablePath: -44-44Chrome 155 by hand + PlaywrightconnectOverCDPChrome 155 by hand + Playwright connectOverCDP: 8787–headless –enable-automation–headless –enable-automation: 143143-50050100150200250 pxHeight of the browser UI the page can infer: outerHeight minus innerHeight. Identical in all 5 runs of each configuration.. Bar chart of outerHeight minus innerHeight by Configuration.12 rows, drawn from the table that follows this chart.Chrome 155, headed, noautomation (control)Chrome 155, headed, no automation (control): 8787Chrome 155 –headless, nolibraryChrome 155 –headless, no library: 8787Playwright, defaults(headless shell 148)Playwright, defaults (headless shell 148): 00Playwright, new headless(CfT 148)Playwright, new headless (CfT 148): 00Playwright, channel'chrome' (Chrome 155)Playwright, channel 'chrome' (Chrome 155): 00Playwright, headless:false (CfT 148)Playwright, headless: false (CfT 148): 8080Playwright, defaults +common flagsPlaywright, defaults + common flags: 00Puppeteer, defaults (CfT129)Puppeteer, defaults (CfT 129): 224224Puppeteer, headless:'shell' (129)Puppeteer, headless: 'shell' (129): 00Puppeteer, Chrome 155 asexecutablePathPuppeteer, Chrome 155 as executablePath: -44-44Chrome 155 by hand +Playwright connectOver…Chrome 155 by hand + Playwright connectOverCDP: 8787–headless–enable-automation–headless –enable-automation: 143143-1000100200300 px
Configuration outerHeight minus innerHeight outer (px) inner (px) screen (px)
Chrome 155, headed, no automation (control) 87 1200 x 824 1200 x 737 1470 x 956
Chrome 155 –headless, no library 87 756 x 556 756 x 469 800 x 600
Playwright, defaults (headless shell 148) 0 1280 x 720 1280 x 720 1280 x 720
Playwright, new headless (CfT 148) 0 1280 x 720 1280 x 720 1280 x 720
Playwright, channel 'chrome' (Chrome 155) 0 1280 x 720 1280 x 720 1280 x 720
Playwright, headless: false (CfT 148) 80 1282 x 800 1280 x 720 1280 x 720
Playwright, defaults + common flags 0 1280 x 720 1280 x 720 1280 x 720
Puppeteer, defaults (CfT 129) 224 1200 x 824 800 x 600 1470 x 956
Puppeteer, headless: 'shell' (129) 0 800 x 600 800 x 600 800 x 600
Puppeteer, Chrome 155 as executablePath -44 756 x 556 800 x 600 800 x 600
Chrome 155 by hand + Playwright connectOverCDP 87 1200 x 824 1200 x 737 1470 x 956
–headless –enable-automation 143 756 x 556 756 x 413 800 x 600

A page can infer how tall the browser’s own interface is by subtracting innerHeight from outerHeight. The control’s was 87 pixels. The headless shell reported 0 under every launcher. So did Playwright’s two headless runs on real Chrome builds, where Playwright makes the window and the viewport the same 1280 by 720 and reports a screen of that size too.

Two rows are stranger. Puppeteer driving Chrome 155 reported an outer window of 756 by 556 holding an 800 by 600 viewport, a browser frame 44 pixels shorter than the page inside it. No real window does that. And Chrome started with --enable-automation reported 143 pixels where plain headless reported 87. In a side check of six more page loads, not part of the data files, headed Chrome 155 lost exactly the same 56 pixels when that switch was added, 143 against 87 in three runs each. The likeliest occupant is the “Chrome is being controlled by automated test software” bar the switch turns on. We did not screenshot it.

Screens tell a similar story. The test machine’s display is 1470 by 956 and the control reported that, with a colour depth of 30. Every headless configuration except Puppeteer on Chrome 129 reported a smaller screen than that, at a colour depth of 24. Playwright’s headed run reported a 1280 by 720 screen holding an 800-pixel-tall window.

What the common evasion flags changed

A common fix for a detected Playwright script is two lines: launch with --disable-blink-features=AutomationControlled, and set a user agent with HeadlessChrome replaced by Chrome. We added a locale as well, because the missing Accept-Language header is the next thing people notice. Against Playwright’s defaults on the same headless shell build, the result was this.

Check Playwright defaults With the common flags Effect
navigator.webdriver is true differs matches fixed
User-Agent contains HeadlessChrome differs matches fixed
User-Agent carries a full, unreduced version differs differs unchanged
Client-hint brands include HeadlessChrome differs differs unchanged
No Accept-Language header differs matches fixed
Accept-Language sent out of the control position matches differs introduced
navigator.languages differs from the control differs differs unchanged
navigator.plugins is empty differs differs unchanged
WebGL missing or SwiftShader differs differs unchanged
Notification.permission contradicts the Permissions API differs differs unchanged
window.chrome is undefined differs differs unchanged
outerHeight minus innerHeight is 0 or negative differs differs unchanged
screen size is not the real display differs differs unchanged
navigator.connection.rtt is 0 differs differs unchanged

Three checks fixed, one introduced, ten left as they were. The score went from 13 to 11. Everything the flags touch lives in the user agent string and one Blink feature. Everything they leave sits in the binary: plugins, WebGL, the permission pair, window.chrome, the geometry and the round-trip time. A partial disguise of this kind also creates the user agent and client-hint contradiction shown above, which the undisguised default did not have.

The DevTools console check only fired on Chrome 129

One check for an attached DevTools client logs an Error whose stack property is a getter. When a client has enabled the protocol’s Runtime domain, the browser builds a preview of every console argument and reads that getter, so the page can tell. We ran it first on the page, before anything else could log.

Configuration Browser build Runs where the getter fired
Puppeteer, defaults (CfT 129) Chrome for Testing 129.0.6668.58 5 of 5
Puppeteer, headless: 'shell' (129) chrome-headless-shell 129.0.6668.58 5 of 5
Puppeteer, Chrome 155 as executablePath Google Chrome 155.0.8059.85 0 of 5
Playwright, defaults (headless shell 148) chrome-headless-shell 148.0.7778.96 0 of 5
Playwright, new headless (CfT 148) Chrome for Testing 148.0.7778.96 0 of 5
Playwright, channel 'chrome' (Chrome 155) Google Chrome 155.0.8059.85 0 of 5
Playwright, headless: false (CfT 148) Chrome for Testing 148.0.7778.96 0 of 5
Playwright, defaults + common flags chrome-headless-shell 148.0.7778.96 0 of 5
Chrome 155 by hand + Playwright connectOverCDP Google Chrome 155.0.8059.85 0 of 5
Chrome 155, headed, no automation (control) Google Chrome 155.0.8059.85 0 of 5

It fired in all 10 runs on Chrome 129 and in none of the 35 runs on Chrome 148 or 155 that had Playwright or Puppeteer attached. Puppeteer on Chrome 155 is the decisive row: same library, newer browser, no signal. So the check works, since it returned a positive, and it is tied to the browser version. A negative on a current Chrome does not mean no client is attached. Other ways of detecting the protocol exist, and we tested only this one.

Two differences we did not explain

speechSynthesis.getVoices() returned 19 voices whenever we started Chrome 155 ourselves: the control, plain --headless, every single-switch run and the hand-started Chrome with Playwright attached. It returned 180 in all eight configurations that Playwright or Puppeteer launched, and in the bare headless shell with no library at all. Each count was the same in all five runs, so it is not a loading race. We did not narrow it to a cause.

The canvas hash split by renderer. Chrome 155 and Chrome for Testing 148 in new headless mode drew exactly the same pixels as the control, hash 4af6377f202aee69. The headless shell and the Chrome 129 builds each drew something different. Global names on window followed the browser version. On Chrome 155 neither library added a single global name compared with the control. On the older builds every extra name was a browser interface such as SharedStorage, and the bare headless shell showed the same ones with no library involved.

Reading the results

Every signal here is produced by the browser. A proxy changes the address a site sees and nothing in these tables, so the choice between residential and datacenter addresses cannot fix a window.chrome that is undefined. The same holds for the handshake one layer down, measured in our TLS ClientHello study.

The binary matters more than the library. Both libraries looked alike on the same Chrome 155 build, at 4 checks each, and alike again on the headless shell, at 13 and 14. For anyone building an agent that browses, the first decision our guide to proxies for AI agents describes, which fetch layer you use, is also the decision that sets most of this table.

And a block page rarely names the signal behind it. When a site refuses a request, the status code is the first evidence you have, and our guide to 403, 407 and 429 responses covers how to read it before guessing at fingerprinting.

Reproducing it

The full probe page and runner produced the downloadable data. The script below is the smallest version that shows the headless-shell result. It needs Node and Playwright and nothing else, and it only ever talks to 127.0.0.1.

// mini.js: the smallest version of the probe. Node 24.2.0, Playwright 1.62.1.
// npm i playwright@1.62.1 && npx playwright install chromium
// CHROME_PATH is optional; leave it unset to use Playwright's own browser.
const http = require('http');
const { chromium } = require(process.env.PLAYWRIGHT || 'playwright');

const PAGE = `<script>
(async () => {
  const gl = document.createElement('canvas').getContext('webgl');
  const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
  const r = {
    webdriver: navigator.webdriver,
    brands: navigator.userAgentData.brands.map(b => b.brand).join(', '),
    plugins: navigator.plugins.length,
    languages: navigator.languages.join(', '),
    webgl: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL).slice(0, 40) : 'none',
    notification: Notification.permission,
    permissionsApi: (await navigator.permissions.query({ name: 'notifications' })).state,
    windowChrome: typeof window.chrome,
    uiHeight: outerHeight - innerHeight,
    rtt: navigator.connection.rtt,
  };
  fetch('/r', { method: 'POST', body: JSON.stringify(r) });
})();
</script>`;

let done;
const server = http.createServer((req, res) => {
  if (req.url === '/') {
    console.log('Accept-Language header:', req.headers['accept-language'] ?? '(none)');
    console.log('sec-ch-ua header:     ', req.headers['sec-ch-ua']);
    return res.end(PAGE);
  }
  let body = '';
  req.on('data', c => (body += c)).on('end', () => { res.end(); done(JSON.parse(body)); });
});

server.listen(0, '127.0.0.1', async () => {
  const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH });
  const page = await browser.newPage();
  const result = new Promise(r => (done = r));
  await page.goto(`http://127.0.0.1:${server.address().port}/`);
  console.log(await result);
  await browser.close();
  server.close();
});

Run with CHROME_PATH pointing at the cached chrome-headless-shell 148.0.7778.96 build, it printed this on 21 October 2026:

Accept-Language header: (none)
sec-ch-ua header:      "Chromium";v="148", "HeadlessChrome";v="148", "Not/A)Brand";v="99"
{
  webdriver: true,
  brands: 'Chromium, HeadlessChrome, Not/A)Brand',
  plugins: 0,
  languages: 'en-US',
  webgl: 'ANGLE (Google, Vulkan 1.3.0 (SwiftShader',
  notification: 'denied',
  permissionsApi: 'prompt',
  windowChrome: 'undefined',
  uiHeight: 0,
  rtt: 0
}

To compare against a normal browser, open the same page in your everyday Chrome while the server is running and read the values from the page with DevTools closed. Your control will differ from ours in screen size, GPU and languages, and that is the point: these checks only mean something against the browser they claim to be.

Limits

  • One machine. Every run used the same Apple M5 Mac on macOS 26.6.2. We did not test Linux, where a server with no display or GPU is likely to give different WebGL, screen and voice results.
  • Loopback HTTP/1.1, no TLS. The server saw plain HTTP from 127.0.0.1. TLS and HTTP/2 fingerprints were not measured here.
  • Versions move. Playwright ran browser build 1223 (148.0.7778.96) instead of its pinned build 1234, and Puppeteer 23.4.0 is older than Playwright 1.62.1. Each row describes the build named in it.
  • Fresh profiles. A real user’s Chrome carries extensions, history and settings that a new profile does not. Our control is one fresh-profile Chrome, not a sample of real users, and a difference from it is not automatically a bot signal.
  • No behaviour. Mouse movement, typing, scrolling and timing were not measured. Each run loaded one page and read static properties.
  • No stealth plugins. Only the libraries already installed were tested. We did not install or test any plugin that patches these properties.
  • One CDP check. The console getter test is one of several ways to notice an attached DevTools client. A negative result covers only that test.
  • Redactions. The time zone is published only as a flag saying whether it matched the control. It did in all 80 runs. File paths and profile directories were removed from browser command lines.

Download the data

Every page load is published, one row per run, with the raw navigation headers in the order received. The data is licensed CC BY 4.0: reuse it with a link to this page.

An empty CSV cell, or null in the JSON, means no value was captured for that field. It never means zero.

Download the data

The raw results behind this study, as files. Reuse them under CC BY 4.0 with a link to this page.

Frequently asked questions

What is bot detection?
Bot detection is the set of checks a site runs to decide whether a request comes from a person using a browser or from software. It combines what the request carries, such as headers and the TLS handshake, with what a script on the page can read about the browser, such as navigator.webdriver, plugins and WebGL. This study measures the second part, plus the headers, for headless Chrome, Playwright and Puppeteer.
Does headless Chrome set navigator.webdriver?
Not by itself in our run. Chrome 155 with --headless alone reported false in 5 of 5 runs. Adding --enable-automation, --remote-debugging-pipe or --remote-debugging-port=0 made it true in every run. The old chrome-headless-shell reported true even when launched with nothing but a URL.
Why does Playwright set navigator.webdriver without --enable-automation?
Playwright 1.62.1 controls Chrome over --remote-debugging-pipe, and Chromium treats that switch as automation. In our isolation runs the pipe alone set the flag on Chrome 155 in all five runs, with nothing sent over it.
Does --disable-blink-features=AutomationControlled make Playwright look like Chrome?
No. On the headless shell it turned navigator.webdriver false and, together with a rewritten user agent and a locale, cleared 3 of the 13 checks that differed from Chrome. Ten remained, including empty plugins, software WebGL and the permission contradiction, and the Accept-Language header moved to a position the control never used.
Is headless mode or the automation library the bigger giveaway?
The browser build. Playwright and Puppeteer each differed on 4 checks when driving the same Chrome 155, and on 13 and 14 when driving the old headless shell. The library mostly decides which build runs and which switches it gets.
Will a residential proxy hide these signals?
No. Every signal in this study is produced by the browser on your own machine, so it is the same through any proxy, from any country and on any kind of address. A proxy changes the IP address a site sees and nothing in these tables.
Can I reproduce the results?
Yes. The page includes a short Node script that reproduces the headless-shell result with Playwright, and every run is published as CSV and JSON under CC BY 4.0. Your screen, GPU, languages and voice counts will reflect your own machine, so compare against your own normal browser.

Sources

  1. MDN: Navigator.webdriver property
  2. Chromium source: content/child/runtime_features.cc, the switches that enable AutomationControlled
  3. W3C WebDriver: the navigator.webdriver attribute and the webdriver-active flag
  4. Chrome for Developers: Chrome Headless mode and chrome-headless-shell
  5. Playwright docs: Browsers, chromium-headless-shell and the new headless mode
  6. Puppeteer docs: Headless modes
  7. Chromium: User-Agent Reduction
  8. WICG: User-Agent Client Hints
  9. WHATWG Notifications API Standard: permissions integration
  10. MDN: WEBGL_debug_renderer_info extension
  11. MDN: NetworkInformation.rtt

Read this page as Markdown · Quote it freely under CC BY 4.0 with a link back.