Skip to content
Measured at Chrome 154.0.8037.57

Chrome for Testing vs Google Chrome: what a website can tell apart

Chrome for Testing is the Chrome build Google publishes for automated tests, the one Puppeteer and Playwright download (and Selenium, when Chrome is missing). It is built from the same source as Google Chrome, but a website and a server see a different browser: 13 differences as downloaded, starting with the TLS fingerprint. One command-line flag removes ten of them.

Pim
Pim · Clearcote Research
Version, all builds
154.0.8037.57 (Linux x64)
Source commit
73c14f6228d7
Differences from Chrome
13 as downloaded · 3 with the flag · 15 headless shell

Key takeaways

  • As downloaded, Chrome for Testing applies Chromium's testing field-trial configuration. It adds TLS extension 4832 (JA4 t13d1518h2… instead of Chrome's t13d1517h2…), sends Accept-Language near the top of every request, and removes or adds whole APIs: no XSLTProcessor, Topics or Protected Audience, but WebMCP.
  • --disable-field-trial-config removes all ten of those. What remains cannot be switched off: no "Google Chrome" in the Client Hints brand list, no Google speech voices, and the "for testing" bar (hidden by --disable-infobars).
  • Default Puppeteer runs Chrome for Testing without the flag, so it has the Chrome for Testing TLS fingerprint. Playwright passes the flag, but its default headless browser is chrome-headless-shell.
  • chrome-headless-shell is a different browser: a third JA4, no Accept-Language header, the full version in the User-Agent, no window.chrome, no PDF viewer and no plugins.

What Chrome for Testing is

Google Chrome updates itself, and Google does not publish downloads of specific Chrome versions. Automated tests need the opposite: one pinned version, and the ChromeDriver that matches it. Since 2023 Google has published Chrome for Testing for that: a separate build of every Chrome release, in every channel, that never updates. Each version comes as three downloads, chrome, chromedriver and chrome-headless-shell, for Windows, macOS and Linux.

It is not a browser for people. It has no installer and no auto-update, cannot be the default browser, and every window carries a bar that cannot be closed: "Chrome for Testing is only for automated testing". Get it from Google's version dashboard or with the command below.

bash
npx @puppeteer/browsers install chrome@stable        # Chrome for Testing
npx @puppeteer/browsers install chrome-headless-shell@stable
# or directly:
curl -sSO https://storage.googleapis.com/chrome-for-testing-public/154.0.8037.57/linux64/chrome-linux64.zip

Two things are called headless Chrome. --headless=new is the real browser without a window. chrome-headless-shell is the old headless implementation, a separate lightweight binary that shares no browser-level code with Chrome; since Chrome 132 it exists only as this download.

The four builds side by side

All four are version 154.0.8037.57 from the same commit, run on one Linux machine with a fresh profile and no automation flags, three times each. The headless-shell column is compared with Google Chrome in --headless=new mode.

Google ChromeChrome for TestingCfT + flagheadless shell
Differences from Chrome–13315
TLS (JA4)t13d1517h2_8daaf6152771_cb7bf5808d99t13d1518h2_8daaf6152771_4980c97edce0Same as Chromet13d1516h2_8daaf6152771_806a8c22fdea
HTTP/2Chrome'sSame as ChromeSame as ChromeSame as Chrome
Accept-LanguageLast, after Accept-EncodingEarly, right after sec-ch-ua-platformSame as ChromeNot sent at all
Sec-CH-UA brandsChromium;v=154, Google Chrome;v=154, Not A(Brand;v=99Not A(Brand;v=99, Chromium;v=154Not A(Brand;v=99, Chromium;v=154Chromium;v=154, HeadlessChrome;v=154, Not A(Brand;v=99
WindowNone“for testing” bar, 56 px“for testing” bar, 56 pxNo browser window at all

HTTP/2 is the same in all four: 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p. Compare your own browser in the JA4 fingerprint checker.

As downloaded: 13 differences

Ten come from the testing field-trial configuration and three from the build itself. The checks marked with a host run on that TLS echo page, which shows what the server received. The Google Chrome values are from a fresh profile: an installed Chrome also runs Google's own field-trial configuration, which can switch some of the same features (in our seeded Chrome 154 runs, unload was blocked in every profile and Protected Audience was gone in one of eight).

high impactMeasured on both builds

An extra TLS extension (4832, server padding) changes the JA4 fingerprint

Default Chrome for Testing adds TLS extension 4832 (0x12E0) to every ClientHello: BoringSSL's experimental request for server-side handshake padding, sent with a two-byte payload of 0000. Nothing else in the handshake changes, so the extension count in JA4 goes from 17 to 18: t13d1518h2_8daaf6152771_4980c97edce0 instead of Google Chrome's t13d1517h2_8daaf6152771_cb7bf5808d99 (ja3n a3a3161a080b73bda9cc285fb367fcc0 instead of bd4930bd9b000ee684830e44bab76fdf). Open https://tls.peet.ws/api/all and run the first two checks there; run the third on https://tls.browserleaks.com/json. HTTP/2 settings, window update and priority are identical.

On tls.peet.ws/api/allJSON.parse(document.querySelector('pre').textContent).tls.ja4
Google Chrome
"t13d1517h2_8daaf6152771_cb7bf5808d99"
Chrome for Testing
"t13d1518h2_8daaf6152771_4980c97edce0"
  • on tls.peet.ws/api/all: JSON.parse(document.querySelector('pre').textContent).tls.extensions.some((e) => e.name === 'Unknown extension 4832')false→ true
  • on tls.browserleaks.com/json: JSON.parse(document.querySelector('pre').textContent).ja4"t13d1517h2_8daaf6152771_cb7bf5808d99"→ "t13d1518h2_8daaf6152771_4980c97edce0"

Why it matters: JA4 is the most widely logged TLS client fingerprint, and a capture from default CfT is not a Google Chrome fingerprint: no Chrome 154 sends extension 4832 unless a field trial switches it on. The testing config's PqcBandwidthExperiment study enables AddTLSServerHandshakePadding with 0 bytes. Google Chrome 154 with only that feature enabled sends the identical ClientHello (measured), and CfT with --disable-field-trial-config sends Chrome's.

high impactMeasured on both builds

Accept-Language moves to the front of the request headers

Default CfT sends Accept-Language right after sec-ch-ua-platform; Google Chrome sends it last, after Accept-Encoding (followed only by priority on HTTP/2). The value is the same (en-US,en;q=0.9). Measured on 19 of 21 request types over HTTP/1.1 (navigations, iframes, fetch, XHR, images, scripts, modules, styles, fonts, media, beacons, CORS preflights, favicons, and requests carrying high-entropy client hints) and in the HTTP/2 HEADERS frame. The WebSocket upgrade and the dedicated-worker script request keep it last. Run the check on https://tls.peet.ws/api/all.

On tls.peet.ws/api/all((h) => h[h.findIndex((x) => x.startsWith('accept-language:')) - 1].split(':')[0])(JSON.parse(document.querySelector('pre').textContent).http2.sent_frames.find((f) => f.frame_type === 'HEADERS').headers)
Google Chrome
"accept-encoding"
Chrome for Testing
"sec-ch-ua-platform"

Why it matters: Header order is fixed per browser build and cannot be changed from a page, which is why servers compare it with the claimed User-Agent. The testing config enables ReduceAcceptLanguage and ReduceAcceptLanguageHTTP; either one alone moves the header on Google Chrome 154 (measured).

high impactMeasured on both builds

navigator.languages lists one language while the header lists two

With an English (US) locale, default CfT returns ["en-US"] from navigator.languages in windows and workers; Google Chrome returns ["en-US","en"]. The Accept-Language header still says en-US,en;q=0.9 on both.

navigator.languages
Google Chrome
["en-US","en"]
Chrome for Testing
["en-US"]

Why it matters: The page and the header disagree about the language list, which no Google Chrome does with default settings. Enabling ReduceAcceptLanguage alone reproduces this on Google Chrome 154; ReduceAcceptLanguageHTTP alone does not (measured). The value depends on the browser's language settings, so the live column skips it.

medium impactMeasured on both builds

DOMParser uses the Rust XML parser, so XML errors read differently

Google Chrome 154 parses XML from DOMParser and XMLHttpRequest with libxml2. Default CfT 154 uses the Rust parser, so every XML error message differs: position, wording and punctuation. Measured for four malformed documents. XSLT-styled XML is not affected.

new DOMParser().parseFromString('<a><b></a>', 'application/xml').querySelector('parsererror div').textContent.trim()
Google Chrome
"error on line 1 at column 11: Opening and ending tag mismatch: b line 1 and a"
Chrome for Testing
"error on line 1 at column 10: Unexpected closing tag: a != b"

Why it matters: This check is also used to tell Chrome versions apart: 153 used the Rust parser, and 154 went back to libxml2. Default CfT 154 therefore gives the Chrome 153 answer. The testing config's XMLParsingRustNonXslt study enables XMLRustForNonXslt. The testing config mirrors field trials that Google also runs on ordinary Chrome installs, so a Google Chrome enrolled in the same experiment can show the CfT value; the live column skips this check.

high impactMeasured on both builds

XSLT is switched off: no XSLTProcessor

XSLTProcessor does not exist in default CfT 154. Google Chrome 154 still has it, and still runs XSLT, with a deprecation banner in the output.

typeof XSLTProcessor
Google Chrome
"function"
Chrome for Testing
"undefined"

Why it matters: The testing config carries XSLTSpecialTrial, which disables the XSLT feature. The flag's own comment says that trial is meant for pre-stable channels, yet default CfT applies it to the stable build. The testing config mirrors field trials that Google also runs on ordinary Chrome installs, so a Google Chrome enrolled in the same experiment can show the CfT value; the live column skips this check.

medium impactMeasured on both builds

WebMCP is exposed: ModelContext, document.modelContext and the tools policy

Default CfT exposes WebMCP, which Google Chrome 154 does not ship. It adds the ModelContext and WebMCPEvent globals, document.modelContext, SubmitEvent.prototype.agentInvoked and respondWith, and the tools permissions-policy feature. The window gains 2 globals and loses 1 (XSLTProcessor), so it has 1227 own properties against 1226.

typeof ModelContext
Google Chrome
"undefined"
Chrome for Testing
"function"
  • 'modelContext' in documentfalse→ true
  • 'agentInvoked' in SubmitEvent.prototypefalse→ true
  • document.featurePolicy.features().includes('tools')false→ true

Why it matters: An API that no shipping Chrome exposes is a clear marker of the testing config. The WebMCP runtime flag follows its base feature only when that feature is overridden, and the testing config's WebMCP study does override it. The testing config mirrors field trials that Google also runs on ordinary Chrome installs, so a Google Chrome enrolled in the same experiment can show the CfT value; the live column skips this check.

medium impactMeasured on both builds

The Topics API is missing

Default CfT has no document.browsingTopics, no browsingTopics attribute on HTMLIFrameElement, HTMLImageElement or Image, and no browsing-topics or interest-cohort permissions-policy features. Google Chrome 154 still has all of them.

'browsingTopics' in document
Google Chrome
true
Chrome for Testing
false
  • document.featurePolicy.features().includes('browsing-topics')true→ false

Why it matters: The testing config's TopicsDeprecation study disables BrowsingTopics. A Chrome 154 claim without Topics comes from a testing-config build, or from a Chrome that received the same experiment from the server. The testing config mirrors field trials that Google also runs on ordinary Chrome installs, so a Google Chrome enrolled in the same experiment can show the CfT value; the live column skips this check.

medium impactMeasured on both builds

Protected Audience is missing: 13 navigator members

Default CfT has none of the Protected Audience members on navigator: runAdAuction, joinAdInterestGroup, leaveAdInterestGroup, clearOriginJoinedAdInterestGroups, updateAdInterestGroups, getInterestGroupAdAuctionData, createAuctionNonce, canLoadAdAuctionFencedFrame, adAuctionComponents, deprecatedURNToURL, deprecatedReplaceInURN, deprecatedRunAdAuctionEnforcesKAnonymity and protectedAudience. Google Chrome 154 has all 13.

'runAdAuction' in navigator
Google Chrome
true
Chrome for Testing
false
  • 'joinAdInterestGroup' in navigatortrue→ false

Why it matters: Navigator.prototype is one of the most commonly enumerated objects, so 13 missing members stand out. The testing config's ProtectedAudienceDeprecation study disables AdInterestGroupAPI and Fledge. The testing config mirrors field trials that Google also runs on ordinary Chrome installs, so a Google Chrome enrolled in the same experiment can show the CfT value; the live column skips this check.

medium impactMeasured on both builds

Attribution Reporting is missing

Default CfT has no attributionSrc on HTMLAnchorElement, HTMLAreaElement, HTMLImageElement, HTMLScriptElement or Image, and no XMLHttpRequest.prototype.setAttributionReporting. Google Chrome 154 has them.

'attributionSrc' in HTMLAnchorElement.prototype
Google Chrome
true
Chrome for Testing
false
  • 'setAttributionReporting' in XMLHttpRequest.prototypetrue→ false

Why it matters: The testing config's AttributionReportingDeprecation study disables ConversionMeasurement. The testing config mirrors field trials that Google also runs on ordinary Chrome installs, so a Google Chrome enrolled in the same experiment can show the CfT value; the live column skips this check.

low impactMeasured on both builds

unload is not allowed on a fixed list of hosts, including 127.0.0.1 and www.google.com

On the hosts in the testing config's allow-list (a.com to f.com, web-platform.test, www1.web-platform.test, 127.0.0.1, example.test, www.google.com), default CfT removes unload from the allowed permissions-policy features, so unload handlers stop running there. Measured false on http://127.0.0.1 and https://www.google.com. It stays true on example.com, tls.peet.ws and tls.browserleaks.com, as in Google Chrome.

document.featurePolicy.allowedFeatures().includes('unload')
Google Chrome
true
Chrome for Testing
false

Why it matters: This matters mostly for local test servers: on 127.0.0.1 under default CfT the permissions policy blocks unload handlers that Google Chrome would run. The testing config's DeprecateUnload study enables DeprecateUnload and DeprecateUnloadByAllowList with this allow-list and a 1% rollout parameter. We only measured the four hosts above; the rollout parameter could also affect other hosts. On Google Chrome, the same study with the same parameters reproduces the result (measured). An installed Google Chrome is different again: Google's own field-trial configuration, which it applies from its second start, blocked unload in every seeded Chrome 154 profile we measured, and on example.com in an installed Chrome 154 on Windows. So this difference holds against a fresh Chrome profile, not against a typical installed one.

high impactMeasured on both builds

No "Google Chrome" in the User-Agent Client Hints brand list

The User-Agent string is identical (Chrome/154.0.0.0), but the brand list is not. CfT reports only the GREASE brand and Chromium. With two entries instead of three, the version-seeded shuffle puts the GREASE brand first: "Not A(Brand";v="99", "Chromium";v="154", against Google Chrome's "Chromium";v="154", "Google Chrome";v="154", "Not A(Brand";v="99". This shows in Sec-CH-UA on every request, in Sec-CH-UA-Full-Version-List, and in navigator.userAgentData in windows and workers.

navigator.userAgentData.brands.map((b) => `${b.brand};v=${b.version}`).join(', ')
Google Chrome
"Chromium;v=154, Google Chrome;v=154, Not A(Brand;v=99"
Chrome for Testing
"Not A(Brand;v=99, Chromium;v=154"

Why it matters: This is the one place where CfT identifies itself. A Chrome/ User-Agent whose Sec-CH-UA has no Google Chrome entry comes from CfT or another Chromium build. CfT is built with CHROMIUM_BRANDING, and that build flag leaves the product brand out of the list.

medium impactMeasured on both builds

The "Chrome for Testing" info bar takes 56 px from the viewport (unless --disable-infobars)

Launched without automation switches, every CfT window shows a bar that cannot be dismissed: "Chrome for Testing v154.0.8037.57 is only for automated testing. For regular browsing, use a standard version of Chrome that updates automatically." The bar is also there in --headless=new. It takes 56 px of height from the page: innerHeight is 917 against 973 in a 1060 px tall window (Xvfb), and 437 against 493 in the default headless window. Sec-CH-Viewport-Height, visualViewport.height and viewport-dependent layout change with it, and in our headful window (orientation: landscape) flipped to true. Launch switches change the picture (measured). --disable-infobars, which Playwright and Puppeteer pass, removes every info bar in CfT in any mode, but in Google Chrome only in headless mode. So with Puppeteer's headful defaults (--enable-automation --disable-infobars) the difference reverses: Google Chrome shows the automation bar (143) and CfT shows none (87). With ChromeDriver's switches (--enable-automation --test-type=webdriver), both show one bar (143), so there is no difference.

outerHeight - innerHeight
Google Chrome
87
Chrome for Testing
143

Why it matters: The gap between window height and viewport height is a common consistency check. Without switches, Google Chrome's UI takes 87 px in the same window and CfT's takes 143 px. Besides --disable-infobars, the CfT bar is also hidden by the CommandLineFlagSecurityWarningsEnabled enterprise policy and by the test switch --test-type=gpu (measured: it removes the bar, headless and headful, and changes nothing else we measure). The value depends on the window, the UI and the launch switches, so the live column skips it.

medium impactMeasured on both builds

No Google speech-synthesis voices

speechSynthesis.getVoices() lists 19 network voices named "Google …" in Google Chrome ("Google US English", "Google Deutsch" and so on) and none in CfT. On our Linux host there are no other voices, so CfT's list is empty. On Windows and macOS the operating system's own voices would still be listed.

new Promise((r) => { speechSynthesis.getVoices(); setTimeout(() => r(speechSynthesis.getVoices().filter((v) => v.name.startsWith('Google')).length), 1000); })
Google Chrome
19
Chrome for Testing
0

Why it matters: Voice lists are a common fingerprint input. The Google voices come from a component extension that only Chrome-branded builds load. The count also drops to 0 in Google Chrome when component extensions are disabled, which some automation defaults do, so the live column skips it.

With --disable-field-trial-config: 3 differences

With the flag, the TLS ClientHello, HTTP/2 and header order are exactly Chrome's, and so is every API. Three build differences remain, with the same values as above:

So a server can tell it from Chrome only by the Sec-CH-UA header, and a page by the brand list, the missing Google voices and, unless --disable-infobars is passed, a viewport 56 px shorter.

chrome-headless-shell: 15 differences

chrome-headless-shell applies no testing configuration, so the APIs above are as in Chrome. Its differences come from being a different program around the same engine, and no flag removes them.

high impactMeasured on both builds

The User-Agent carries the full version: HeadlessChrome/154.0.8037.57

chrome-headless-shell does not use the reduced User-Agent. Every request and navigator.userAgent, in windows and workers, carries the full build number: HeadlessChrome/154.0.8037.57. Google Chrome always sends …0.0.0: Chrome/154.0.0.0 in a window and HeadlessChrome/154.0.0.0 in --headless mode. The chrome values below are from Google Chrome in --headless=new mode.

/Chrome\/\d+\.0\.0\.0 /.test(navigator.userAgent)
Google Chrome
true
chrome-headless-shell
false
  • navigator.userAgent.split(' ').find((t) => t.includes('Chrome/'))"HeadlessChrome/154.0.0.0"→ "HeadlessChrome/154.0.8037.57"

Why it matters: No Chrome since the User-Agent reduction sends a full build number in the User-Agent. chrome-headless-shell builds its product token from the full PRODUCT_VERSION.

high impactMeasured on both builds

"HeadlessChrome" replaces "Google Chrome" in the brand list

Sec-CH-UA, Sec-CH-UA-Full-Version-List and navigator.userAgentData name HeadlessChrome as the browser brand: "Chromium";v="154", "HeadlessChrome";v="154", "Not A(Brand";v="99". Google Chrome in --headless=new mode still reports Google Chrome.

navigator.userAgentData.brands.map((b) => `${b.brand};v=${b.version}`).join(', ')
Google Chrome
"Chromium;v=154, Google Chrome;v=154, Not A(Brand;v=99"
chrome-headless-shell
"Chromium;v=154, HeadlessChrome;v=154, Not A(Brand;v=99"

Why it matters: The brand list states the headless product outright, on every request.

high impactMeasured on both builds

No Accept-Language header at all

chrome-headless-shell sends no Accept-Language header on any request: navigations, subresources, fetch, WebSocket, or the HTTP/2 HEADERS frame. navigator.languages has one entry. The server also never receives a /favicon.ico request. Run the first check on https://tls.peet.ws/api/all.

On tls.peet.ws/api/allJSON.parse(document.querySelector('pre').textContent).http2.sent_frames.find((f) => f.frame_type === 'HEADERS').headers.some((h) => h.startsWith('accept-language:'))
Google Chrome
true
chrome-headless-shell
false
  • navigator.languages["en-US","en"]→ ["en-US"]

Why it matters: Every desktop browser sends Accept-Language. A request without it, combined with a Chrome User-Agent, is unusual on its own.

high impactMeasured on both builds

No Trust Anchor IDs extension: a third JA4

chrome-headless-shell leaves out TLS extension 51764 (0xCA34, Trust Anchor IDs), so JA4 is t13d1516h2_8daaf6152771_806a8c22fdea (ja3n 8e19337e7524d2573be54efb2b0784c9). Google Chrome sends t13d1517h2_8daaf6152771_cb7bf5808d99. Everything else in the ClientHello and the HTTP/2 fingerprint is identical. Run the checks on https://tls.peet.ws/api/all.

On tls.peet.ws/api/allJSON.parse(document.querySelector('pre').textContent).tls.ja4
Google Chrome
"t13d1517h2_8daaf6152771_cb7bf5808d99"
chrome-headless-shell
"t13d1516h2_8daaf6152771_806a8c22fdea"
  • on tls.peet.ws/api/all: JSON.parse(document.querySelector('pre').textContent).tls.extensions.some((e) => e.name === 'Unknown extension 51764')true→ false

Why it matters: Chrome sends Trust Anchor IDs only when the browser has configured trust-anchor IDs from its root store. The headless shell's embedder configures none, so the extension is never sent.

high impactMeasured on both builds

No window.chrome object

window.chrome (with app, csi and loadTimes) does not exist. The window has 1225 own properties against 1226.

typeof window.chrome
Google Chrome
"object"
chrome-headless-shell
"undefined"

Why it matters: This is one of the oldest and most widely used headless checks. window.chrome is installed by //chrome, and the headless shell does not include //chrome.

high impactMeasured on both builds

No PDF viewer, plugins or mime types

navigator.pdfViewerEnabled is false, and navigator.plugins and navigator.mimeTypes are empty. Google Chrome has 5 plugins ("PDF Viewer", "Chrome PDF Viewer", …) and 2 mime types.

navigator.pdfViewerEnabled
Google Chrome
true
chrome-headless-shell
false
  • navigator.plugins.length5→ 0
  • navigator.mimeTypes.length2→ 0

Why it matters: Google Chrome returns the same fixed list of five PDF plugin entries on every desktop, so an empty list stands out.

medium impactMeasured on both builds

12 permissions report prompt instead of granted or denied

In chrome-headless-shell, navigator.permissions.query() returns prompt for accelerometer, background-fetch, background-sync, clipboard-write, gyroscope, keyboard-lock, magnetometer, payment-handler, pointer-lock, screen-wake-lock and storage-access. Google Chrome returns granted for these. For periodic-background-sync it returns prompt where Google Chrome returns denied.

navigator.permissions.query({ name: 'clipboard-write' }).then((s) => s.state)
Google Chrome
"granted"
chrome-headless-shell
"prompt"
  • navigator.permissions.query({ name: 'background-sync' }).then((s) => s.state)"granted"→ "prompt"
  • navigator.permissions.query({ name: 'periodic-background-sync' }).then((s) => s.state)"denied"→ "prompt"

Why it matters: The permission states are read without prompting the user, and Chrome's defaults are the same on every fresh profile. The headless shell uses its own permission defaults.

high impactMeasured on both builds

No browser UI: the window equals the viewport and sits at 0,0

In chrome-headless-shell, outerWidth × outerHeight, innerWidth × innerHeight and screen are all 800 × 600, and screenX/screenY are 0. Google Chrome in --headless=new mode draws its browser UI into the same 800 × 600 screen: window 780 × 580 at 10,10, viewport 780 × 493. Layout that depends on the viewport width changes with it, as does Sec-CH-Viewport-Width.

outerHeight - innerHeight
Google Chrome
87
chrome-headless-shell
0
  • `${outerWidth}x${outerHeight} ${innerWidth}x${innerHeight} @${screenX},${screenY}`"780x580 780x493 @10,10"→ "800x600 800x600 @0,0"

Why it matters: A window with no toolbar, positioned at the screen origin, is typical of headless browsers. It is also what a kiosk or fullscreen window looks like, so the live column skips it.

low impactMeasured on both builds

<webview> and <controlledframe> are unknown elements

document.createElement('webview') and document.createElement('controlledframe') create HTMLUnknownElement in chrome-headless-shell. In Google Chrome they are plain HTMLElements, because //chrome registers those tag names.

document.createElement('webview') instanceof HTMLUnknownElement
Google Chrome
false
chrome-headless-shell
true

Why it matters: This is a cheap check that separates //chrome from other embedders.

medium impactMeasured on both builds

No Widevine DRM

navigator.requestMediaKeySystemAccess('com.widevine.alpha', …) fails with NotSupportedError. Google Chrome and CfT on Linux both support Widevine (CfT included, measured).

navigator.requestMediaKeySystemAccess('com.widevine.alpha', [{ initDataTypes: ['cenc'], videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.42E01E"' }] }]).then(() => 'supported', (e) => e.name)
Google Chrome
"supported"
chrome-headless-shell
"NotSupportedError"

Why it matters: Whether Widevine is present depends on the platform and on component updates, so the live column skips it. On a desktop that claims to be Chrome, missing Widevine is still unusual.

medium impactMeasured on both builds

No Google speech-synthesis voices

As in CfT, the 19 "Google …" voices are missing. On our Linux host the list is empty.

new Promise((r) => { speechSynthesis.getVoices(); setTimeout(() => r(speechSynthesis.getVoices().filter((v) => v.name.startsWith('Google')).length), 1000); })
Google Chrome
19
chrome-headless-shell
0

Why it matters: The Google voices come from a component extension that only Chrome-branded builds load.

medium impactMeasured on both builds

No network-quality estimate: rtt is 0, and no ECT/RTT/Downlink hints

navigator.connection.rtt was 0 in chrome-headless-shell, with downlink 9 to 10, in all three runs. Google Chrome reported 100 ms on the same host. After an Accept-CH response, the headless shell does not send the ECT, RTT and Downlink hints, although it sends all the others.

navigator.connection.rtt
Google Chrome
100
chrome-headless-shell
0

Why it matters: An RTT of exactly 0 on a network connection is implausible. Google Chrome's value depends on the network (it is rounded to 25 ms steps), so the live column skips it.

low impactMeasured on both builds

Emoji are measured and drawn differently

Canvas measureText returns whole-pixel widths for colour emoji in chrome-headless-shell (12 for 🙂 at the default 10 px font, against 12.47705078125 in Google Chrome). Latin, accented and CJK text measure the same. Canvas hashes of text that contains emoji differ as well.

document.createElement('canvas').getContext('2d').measureText('🙂').width
Google Chrome
12.47705078125
chrome-headless-shell
12

Why it matters: Canvas text is a staple of fingerprinting scripts. The exact numbers depend on the installed emoji font (here Noto Color Emoji on Linux), so the live column skips it.

low impactMeasured on both builds

A different JavaScript heap limit

performance.memory.jsHeapSizeLimit is 3760000000 in chrome-headless-shell and 4395630592 in Google Chrome (and CfT) on the same 32 GB host.

performance.memory.jsHeapSizeLimit
Google Chrome
4395630592
chrome-headless-shell
3760000000

Why it matters: The heap limit is configured by the embedder. The values depend on the host, so the live column skips it.

Why: the testing field-trial configuration

Chromium ships a list of field trials for testing, fieldtrial_testing_config.json, with 1,114 studies at this version. A build that is not Chrome-branded applies it at startup unless it is started with --disable-field-trial-config (variations_field_trial_creator.cc). Chrome for Testing cannot be Chrome-branded (chrome_build.gni), so it applies the list. Google Chrome has the same list compiled in but only applies it with --enable-field-trial-config; started that way, it reproduces all ten differences exactly.

Ten studies explain every difference we measured:

StudyFeaturesWhat a website sees
PqcBandwidthExperiment+AddTLSServerHandshakePadding (0 bytes)TLS extension 4832, a different JA4
ReduceAcceptLanguage+ReduceAcceptLanguageAccept-Language moves early; navigator.languages keeps one entry
ReduceAcceptLanguageHTTP+ReduceAcceptLanguageHTTPAccept-Language moves early
XMLParsingRustNonXslt+XMLRustForNonXsltXML parse errors read like Chrome 153
XSLTSpecialTrial−XSLTno XSLTProcessor
WebMCP+WebMCPModelContext and document.modelContext
TopicsDeprecation−BrowsingTopicsno Topics API
ProtectedAudienceDeprecation−AdInterestGroupAPI, −Fledgeno Protected Audience
AttributionReportingDeprecation−ConversionMeasurementno Attribution Reporting
DeprecateUnload+DeprecateUnload, +DeprecateUnloadByAllowListunload blocked on listed hosts, including 127.0.0.1

Chrome for Testing also never downloads Google's own field-trial configuration, which an installed Chrome fetches and applies from its second start (variations_service.cc). An installed Chrome therefore runs whichever experiments Google has assigned to it, while Chrome for Testing runs the testing list or none.

Which automation tools run Chrome for Testing

Read in each tool's source at the release named. The flag column is whether it passes --disable-field-trial-config.

ToolWhat it runs by defaultFlagSo it looks like
Playwright1.63.0Headless: chrome-headless-shell. Headed, or channel: 'chromium': Chrome for Testing. channel: 'chrome': the installed Google Chrome.Default switches (line 53)Which executableYesDefault headless looks like the headless-shell column; channel: 'chromium' like Chrome for Testing with the flag.
Puppeteer25.12.0Chrome for Testing 154.0.8037.57 with --headless=new; headless: 'shell' runs chrome-headless-shell.Default argumentsPinned versionNoDefault Puppeteer looks exactly like the Chrome for Testing column, TLS fingerprint included. The flag was added in 21.5.2 and removed in 22.8.0.
Selenium (ChromeDriver)4.49.0Selenium Manager downloads Chrome for Testing when Chrome is missing or a version is pinned.Selenium ManagerChromeDriver switchesNoChrome for Testing through Selenium Manager looks like the Chrome for Testing column.
Cypress16.1.0Does not download Chrome for Testing; uses one that is installed.Chromium switchesYesPointed at Chrome for Testing, it looks like the column with the flag.

We measured the bare binaries with the flags named on this page. Each tool adds switches of its own, which can change other values.

Make Chrome for Testing behave like Chrome

  1. Pass --disable-field-trial-config. It removes the ten testing-configuration differences, the TLS and header ones included, and changes nothing on Google Chrome, so it is safe to pass to both.
  2. Pass --disable-infobars to remove the "for testing" bar and its 56 px. Chrome for Testing honours it in any mode; Google Chrome only in headless mode.
  3. Use --headless=new rather than chrome-headless-shell: in Playwright, channel: 'chromium' or 'chrome'; in Puppeteer, headless: true.
  4. Accept what remains: the brand list without "Google Chrome", no Google speech voices, no auto-update and no field-trial configuration from Google. For Google Chrome itself, use the branded build (channel: 'chrome').
bash
./chrome-linux64/chrome --disable-field-trial-config --disable-infobars --user-data-dir="$(mktemp -d)"

Run the checks yourself

Paste the first script into the DevTools console on any https:// page, in each browser you want to compare; it prints every page-side check above with this browser's value. For the network side, open tls.peet.ws/api/all in a fresh tab and paste the second script into its console. Values that depend on the window, the machine or the network (marked in the cards) will differ from ours on other hardware.

chrome-for-testing-probe.js
// Chrome for Testing vs Google Chrome: paste into the DevTools console on any https:// page and press Enter.
// Each line prints the row and the value this browser returns. Expected values: clearcotelabs.com/chrome-for-testing
(async () => {
  const checks = [
    ["cft-navigator-languages", () => navigator.languages],
    ["cft-xml-parser-rust", () => new DOMParser().parseFromString('<a><b></a>', 'application/xml').querySelector('parsererror div').textContent.trim()],
    ["cft-xslt-disabled", () => typeof XSLTProcessor],
    ["cft-webmcp", () => typeof ModelContext],
    ["cft-webmcp", () => 'modelContext' in document],
    ["cft-webmcp", () => 'agentInvoked' in SubmitEvent.prototype],
    ["cft-webmcp", () => document.featurePolicy.features().includes('tools')],
    ["cft-topics-disabled", () => 'browsingTopics' in document],
    ["cft-topics-disabled", () => document.featurePolicy.features().includes('browsing-topics')],
    ["cft-protected-audience-disabled", () => 'runAdAuction' in navigator],
    ["cft-protected-audience-disabled", () => 'joinAdInterestGroup' in navigator],
    ["cft-attribution-reporting-disabled", () => 'attributionSrc' in HTMLAnchorElement.prototype],
    ["cft-attribution-reporting-disabled", () => 'setAttributionReporting' in XMLHttpRequest.prototype],
    ["cft-unload-allowlist", () => document.featurePolicy.allowedFeatures().includes('unload')],
    ["cft-brand-list", () => navigator.userAgentData.brands.map((b) => `${b.brand};v=${b.version}`).join(', ')],
    ["cft-for-testing-infobar", () => outerHeight - innerHeight],
    ["cft-google-voices", () => new Promise((r) => { speechSynthesis.getVoices(); setTimeout(() => r(speechSynthesis.getVoices().filter((v) => v.name.startsWith('Google')).length), 1000); })],
    ["hs-user-agent-full-version", () => /Chrome\/\d+\.0\.0\.0 /.test(navigator.userAgent)],
    ["hs-user-agent-full-version", () => navigator.userAgent.split(' ').find((t) => t.includes('Chrome/'))],
    ["hs-no-window-chrome", () => typeof window.chrome],
    ["hs-no-pdf-viewer", () => navigator.pdfViewerEnabled],
    ["hs-no-pdf-viewer", () => navigator.plugins.length],
    ["hs-no-pdf-viewer", () => navigator.mimeTypes.length],
    ["hs-permission-defaults", () => navigator.permissions.query({ name: 'clipboard-write' }).then((s) => s.state)],
    ["hs-permission-defaults", () => navigator.permissions.query({ name: 'background-sync' }).then((s) => s.state)],
    ["hs-permission-defaults", () => navigator.permissions.query({ name: 'periodic-background-sync' }).then((s) => s.state)],
    ["hs-window-geometry", () => `${outerWidth}x${outerHeight} ${innerWidth}x${innerHeight} @${screenX},${screenY}`],
    ["hs-no-hreftranslate", () => 'hrefTranslate' in HTMLAnchorElement.prototype],
    ["hs-webview-element", () => document.createElement('webview') instanceof HTMLUnknownElement],
    ["hs-no-widevine", () => navigator.requestMediaKeySystemAccess('com.widevine.alpha', [{ initDataTypes: ['cenc'], videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.42E01E"' }] }]).then(() => 'supported', (e) => e.name)],
    ["hs-network-quality", () => navigator.connection.rtt],
    ["hs-emoji-metrics", () => document.createElement('canvas').getContext('2d').measureText('🙂').width],
    ["hs-heap-limit", () => performance.memory.jsHeapSizeLimit],
  ];
  const out = [];
  for (const [id, f] of checks) {
    let v;
    try { v = await f(); } catch (e) { v = "throws " + e.name; }
    out.push(id.padEnd(34) + (typeof v === "string" ? JSON.stringify(v) : JSON.stringify(v) ?? String(v)));
  }
  console.log(out.join("\n"));
})();
chrome-for-testing-network.js
// Chrome for Testing vs Google Chrome, network side: open https://tls.peet.ws/api/all in a fresh tab, then paste this into its console.
// The page shows what the server received; these lines read the fingerprints out of it.
(async () => {
  const checks = [
    ["cft-tls-server-padding", () => JSON.parse(document.querySelector('pre').textContent).tls.ja4],
    ["cft-tls-server-padding", () => JSON.parse(document.querySelector('pre').textContent).tls.extensions.some((e) => e.name === 'Unknown extension 4832')],
    ["cft-accept-language-early", () => ((h) => h[h.findIndex((x) => x.startsWith('accept-language:')) - 1].split(':')[0])(JSON.parse(document.querySelector('pre').textContent).http2.sent_frames.find((f) => f.frame_type === 'HEADERS').headers)],
    ["hs-no-accept-language", () => JSON.parse(document.querySelector('pre').textContent).http2.sent_frames.find((f) => f.frame_type === 'HEADERS').headers.some((h) => h.startsWith('accept-language:'))],
    ["hs-tls-no-trust-anchors", () => JSON.parse(document.querySelector('pre').textContent).tls.extensions.some((e) => e.name === 'Unknown extension 51764')],
  ];
  const out = [];
  for (const [id, f] of checks) {
    let v;
    try { v = await f(); } catch (e) { v = "throws " + e.name; }
    out.push(id.padEnd(34) + (typeof v === "string" ? JSON.stringify(v) : JSON.stringify(v) ?? String(v)));
  }
  console.log(out.join("\n"));
})();

How every Chrome version differs from the one before it, with the same kind of checks, is on the Chrome releases pages.

FAQ

What is Chrome for Testing?

A build of Chrome that Google publishes for automated tests: a fixed version that never updates itself, for every Chrome release and channel, with a matching ChromeDriver and chrome-headless-shell. It is not meant for everyday browsing, and it shows a bar saying so.

Is Chrome for Testing the same as Google Chrome?

Same version, same source commit, but not the same browser to a website. As downloaded it differs in 13 ways at 154.0.8037.57, including its TLS fingerprint (JA4 t13d1518h2… instead of t13d1517h2…), the position of the Accept-Language header and the brand list in Client Hints.

How do I download Chrome for Testing?

From Google's own dashboard, googlechromelabs.github.io/chrome-for-testing, which lists every version with direct download links, or with npx @puppeteer/browsers install chrome@stable.

Why does Chrome for Testing have a different TLS fingerprint?

Its testing field-trial configuration switches on an experiment (PqcBandwidthExperiment) that adds TLS extension 4832, a request for server-side handshake padding. Google Chrome only sends it when a field trial enables it. Start Chrome for Testing with --disable-field-trial-config and its ClientHello matches Chrome's.

What does --disable-field-trial-config do?

It stops a non-branded build such as Chrome for Testing from applying Chromium's testing field-trial configuration. That removes 10 of the 13 differences, including the TLS and header ones. On Google Chrome itself it changes nothing we could measure.

What is the difference between chrome-headless-shell and headless Chrome?

--headless=new runs the real Chrome browser without a window. chrome-headless-shell is the old headless implementation, a separate, lighter binary that shares no browser-level code with Chrome, and it differs in 15 ways, from a full version number in the User-Agent to no Accept-Language header at all.

Clearcote puts this into practice

An open-source Chromium with fingerprint control compiled into the engine. A drop-in for Playwright & Puppeteer.

Free for one browser with GitHub. No card.