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.
On this page 14 sections
- Method
- How far each setup was from a normal Chrome window
- Which checks caught what
- What sets navigator.webdriver
- User-Agent and client hints
- Five properties where only the headless shell differed
- Window and screen geometry
- What the common evasion flags changed
- The DevTools console check only fired on Chrome 129
- Two differences we did not explain
- Reading the results
- Reproducing it
- Limits
- 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, andnavigator.userAgentDatabrands plus its high-entropy values- plugins, MIME types and languages
- the unmasked WebGL vendor and renderer
Notification.permissionbesidenavigator.permissions.query()for six permissionswindow.chromeand 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.debugread a getter placed on an Error’sstack
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
| 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
| 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
| 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.
- bot-detection-signals-measured.csv: 80 rows, one per page load, 61 columns including the configuration, run number, every derived field and the header order.
- bot-detection-signals-measured.json: the same 80 runs with full page values and headers, the per-configuration summary, the 15 check definitions and the check matrix.
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?
Does headless Chrome set navigator.webdriver?
Why does Playwright set navigator.webdriver without --enable-automation?
Does --disable-blink-features=AutomationControlled make Playwright look like Chrome?
Is headless mode or the automation library the bigger giveaway?
Will a residential proxy hide these signals?
Can I reproduce the results?
Sources
- MDN: Navigator.webdriver property
- Chromium source: content/child/runtime_features.cc, the switches that enable AutomationControlled
- W3C WebDriver: the navigator.webdriver attribute and the webdriver-active flag
- Chrome for Developers: Chrome Headless mode and chrome-headless-shell
- Playwright docs: Browsers, chromium-headless-shell and the new headless mode
- Puppeteer docs: Headless modes
- Chromium: User-Agent Reduction
- WICG: User-Agent Client Hints
- WHATWG Notifications API Standard: permissions integration
- MDN: WEBGL_debug_renderer_info extension
- MDN: NetworkInformation.rtt