A server that wants more from Chrome than the brand and platform sends an Accept-CH header back, naming the client hints it would like. Chrome files that away against the origin and adds the extra Sec-CH-* headers from the next request on. So your first visit to a site tells it less than your second. If a hint matters for the very first page, the server can also send Critical-CH, and Chrome will throw away the response it just got and ask again with the hint attached. The server sees both requests, a round trip apart, and nothing stops it comparing them.

What Chrome sends before an Accept-CH header asks

Ask for nothing and you get three: Sec-CH-UA (the brand list), Sec-CH-UA-Mobile and Sec-CH-UA-Platform, on every request to a secure origin. The rest stay home until invited. That covers a lot, from the full version list, platform version, architecture, bitness and device model to the Sec-CH-Prefers-* media preferences and the three network hints RTT, Downlink and ECT.

An invitation looks like this, on a navigation response:

Accept-CH: Sec-CH-UA-Full-Version-List, Sec-CH-UA-Bitness, Sec-CH-Prefers-Color-Scheme

Plain HTTP gets ignored; HTTPS or a local address like 127.0.0.1 counts, and the header has to come with the top-level document. After that the origin keeps getting those hints until somebody clears its site data. A script or image from another host on the same page gets none of them unless the page hands them over with Permissions-Policy. RFC 8942 is the HTTP Client Hints specification behind all this. Chromium's own client hints documentation describes how the opt-in is stored.

Every profile is drawn from one real computer, not from separate listsIn Mosaic: Machines that exist. GPU, screen, CPU and fonts from one real device. Free holds 3 profiles. Pro is $49 a month for any number.

How it works: Machines that exist

What Critical-CH changes

The cost of Accept-CH is that the page which asked never gets the answer. Its own request went out before the opt-in existed. For most hints that does not matter. For a hint that decides what HTML to serve, it does, so a server can mark some hints critical:

Accept-CH: Sec-CH-Prefers-Reduced-Motion
Vary: Sec-CH-Prefers-Reduced-Motion
Critical-CH: Sec-CH-Prefers-Reduced-Motion

When Chrome receives that response, it works out which hints it would send under the new Accept-CH. If one listed in Critical-CH would have been sent but was not, it throws the response away and retries the request with the hint attached. The page loads one round trip later, with what it asked for. Critical-CH must be a subset of Accept-CH, and the hints in it belong in Vary too.

The timing detail a server can see

Look at it from the server's log. A real Chrome hitting a Critical-CH page shows up twice: the URL with three hints, then the same URL a round trip later with the critical ones added. Since that is ordinary Chrome behaviour, it makes a decent baseline. A client whose User-Agent says Chrome but which never comes back for the retry is acting like something else, and so is one that volunteers high-entropy hints to an origin that never asked. There is a second comparison too. Plenty of hints have a JavaScript twin, so a page can read the header and the API and check that they match. That one we had to measure on ourselves.

What we read off our own wire

Our engine can set the network estimate a page reads from navigator.connection, through a switch we had deliberately never turned on. The worry was that the RTT and Downlink headers are built somewhere else in Chromium, so the page and its own request headers could disagree.

Nobody shows those headers back to you, so we wrote a local origin that answers Accept-CH: rtt, downlink, ect, with Critical-CH naming the same three and a Permissions-Policy allowing them. The page reports navigator.connection from the window and from a worker, then navigates to a second page whose requests carry the hints. One profile, patched in place, with a control at both ends:

control, no switches     JS 50    header [50]
control again            JS 50    header [0, 50]
rtt=300 on the command line
                         JS 300   header [0, 50]

In a control, the header carries what the page is told. With the switch on, the page says 300 and the header still says 50. A contradiction like that costs a server one Accept-CH line to find, so the switch stays off.

The run also showed the mechanism at work. No hints came on the first request to the origin, since it had not asked yet; test once and you would write down "no headers" and move on. The hinted page then turned up more than once with different numbers, because Chromium keeps revising its network estimate while a page loads, so we kept every request rather than the first. And loopback is too quick to say anything: rtt reads 0 and downlink pins at its ceiling of 10 until you hold each response back. We held them 400 ms.

The hints that came from this Mac

The same kind of server, asking for every hint, found something worse. Two unrelated profiles both sent Sec-CH-Prefers-Color-Scheme: dark, Sec-CH-Prefers-Reduced-Motion: reduce and Sec-CH-Prefers-Reduced-Transparency: reduce, because this Mac had those settings on. Reduced motion and reduced transparency are settings few people switch on, and any site that asked got both from every profile on the machine. Those preferences are now drawn per profile, with dark mode at 30%, as explained in why 30% of our profiles use dark mode.

The version hints carry the same two-sided risk. In Tesseract Chrome one switch, --fingerprint-brand-version, feeds both the client hint version lists and the User-Agent suffix, so the header a server asked for and the string it got anyway have to agree. A full version with no build number broke that once (a full Chrome version with no build number), and the GREASE entry has to fit the claimed major too (Not_A Brand, computed).