Headless Chrome tells sites it is headless in its own user agent. On stock Chrome 152 we measured the two side by side: the headed browser sends Chrome/152.0.0.0, and the headless one sends HeadlessChrome/152.0.0.0. It also drops the real GPU. So --headless=new, which looks like the obvious way to run background work out of the user's sight, is the one setting that names the browser as automated to every site it touches.

Headless Chrome user agent detection: the two strings

These are the two user agents, from the same build, on the same Mac:

headed    Mozilla/5.0 (…) Chrome/152.0.0.0 Safari/537.36
headless  Mozilla/5.0 (…) HeadlessChrome/152.0.0.0 Safari/537.36

A server sees the second one in the User-Agent header of the very first request, before any script runs. A page sees it again in navigator.userAgent. Detecting headless Chrome by user agent needs no fingerprinting at all, only a substring match.

The GPU is the quieter half. Headless rendering does not use the machine's real graphics stack, so WebGL's renderer string and everything drawn through it come out different from the same profile run headed. A profile whose fingerprint was drawn around a particular GPU, as ours are, stops matching itself.

The fingerprint is changed inside the engine, not painted on topIn Mosaic: Its own Chromium. Tesseract Chrome: values changed in C++, not JavaScript. Free holds 3 profiles. Pro is $49 a month for any number.

How it works: Its own Chromium

The trap: our best profiles leaked it most

Mosaic passes --user-agent to the engine only when a profile's user agent differs from the one the engine would send on its own, because the flag itself empties some of the client hints a real Chrome sends. Usually it does not differ, because a profile is meant to claim the Chrome it really runs. That was the common case and the intended one.

Combine that with headless, as it was before the fix, and the logic inverts. The engine's own user agent, in headless mode, was the HeadlessChrome one. The best-configured profiles, whose claimed user agent equalled the engine's, were exactly the ones that got no override, so they were the ones that announced HeadlessChrome to every site.

And the work that ran headless was warm-up. The cookie robot defaulted to headless, so it collected each profile's cookies from a browser that had identified itself as automated, on a GPU the profile would never use again. That is worse than never warming up. It writes a bot's visit into the history and calls it a reputation. (The same robot had a second problem, in how it scrolled and clicked, written up in the warm-up robot that left a bot signature.)

Headed, parked off-screen

What replaced headless is an ordinary headed browser with its window at --window-position=-32000,-32000. Nobody sees it, the same way nobody sees a headless run. But it is the same engine, the same renderer, the same GPU and the same user agent the profile uses when you open it yourself.

The rule we took from it: background work uses the same browser the profile uses. Any shortcut that changes how the engine describes itself changes who the profile is, and the sites it visited remember the wrong one. It applies to more than warm-up. The leak test and the detection checks in Mosaic measure what a site sees, and a headless browser grades a browser we do not ship, so a measurement never runs headless either.

A Mosaic leak test, run from inside the profile's own page
The leak test runs headed, in the profile's own browser

Headless is still there for a caller who asks for it, for example from the REST API. When it is on, the launcher now always forces a user-agent override, so the token cannot reach a site. The client hints that come with an override are then thinner than a real Chrome's, which is the lesser tell, and the GPU difference remains. We would not use it for anything that touches an account.

What "off-screen" cost on macOS

Replacing one launch mode turned up a fault in code that tested for the old one. Visible profile windows run from a small app bundle with its own identifier, which is what gives them a Dock tile. That step was skipped for headless launches, which have no Dock tile. When background work moved from headless to an off-screen headed window, nothing updated the condition, so every warm-up, leak test and check script started minting a new app identifier, and macOS greets every new identifier with a notifications banner once.

The bug report had two banners named Stock comparison 21:46:58 and Stock comparison 21:47:30, two runs of a check script thirty-two seconds apart. The condition is a function with a full truth table now, and a check pins all sixteen combinations.

If you automate Chrome yourself

Three things carry over to any setup, with or without Mosaic.

  1. Read your own user agent from inside the page you automate, not from your launch flags. If it contains HeadlessChrome, every site can see it.
  2. Check the WebGL renderer in headless and headed runs of the same profile. If they differ, the profile has two fingerprints.
  3. If a window in the way is the problem, park the window instead of removing the browser's head.

Attaching Playwright or Puppeteer to a headed Mosaic profile, rather than launching a headless one, is covered in using Playwright with an anti-detect browser. How the engine itself is built, from stock Chromium with our patches, is on the engine page.