One browser, many header sets
A page load is not one request. It is a navigation, then stylesheets, scripts, images, fetches, maybe an iframe, a worker and a form submission. The Fetch standard gives each request a destination (document, script, image, empty for fetch) and a mode (navigate, no-cors, cors), and browsers derive the headers from those two values: which headers appear, in what order and with which values.
A server that terminates TLS sees all of it: the header list in wire order, over HTTP/2 the pseudo-header order and stream priority, over HTTP/1.1 the casing. A header set is therefore not a property of "Chrome" but of "Chrome loading an image" or "Chrome running a fetch from a worker". It sits one layer above HTTP/2 fingerprinting, which describes the connection, and JA4, which describes the TLS handshake.
What Chrome 156 sends per request kind
Measured against a header-logging server in October 2026:
| Request kind | Opening headers | Accept | priority |
|---|---|---|---|
| Typed navigation | sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform, upgrade-insecure-requests, user-agent | Document list (with image/jxl and signed-exchange) | u=0, i |
| Blocking script | sec-ch-ua-platform, user-agent, sec-ch-ua, sec-ch-ua-mobile | */* | u=1 |
| Stylesheet | Subresource layout | text/css,*/*;q=0.1 | u=0 |
| Page image | Subresource layout | Image list ending image/*,*/*;q=0.8 | u=2, i |
| fetch / XHR | Subresource layout | */* | u=1, i |
| Dedicated worker script | accept: */* first, user-agent after referer | */* | u=4, i |
Some details have fixed positions: content-length is the first header on every POST, origin is the first header on a CORS script, sec-fetch-user: ?1 appears only on user-activated navigations, and fetches made inside a dedicated worker carry no sec-ch-ua* client hints at all. Chrome omits any part of priority that equals the RFC 9218 default, so an async script sends no priority header.
Safari differs everywhere: no client hints, every GET starts with sec-fetch-dest, the tail is accept-language, priority, accept-encoding instead of Chrome's accept-encoding, accept-language, priority, and every request states an explicit urgency.
The mistakes HTTP clients make
- One header set for everything. A captured navigation (
sec-fetch-dest: document,sec-fetch-user: ?1, document Accept,priority: u=0, i) sent on every request, including ones that claim to be an XHR or an image. - Fetch Metadata that contradicts the request, such as
sec-fetch-mode: navigateon a POST with anOriginand a JSON body. - A priority header on HTTP/1.1 from a client that claims to be Chrome, or a priority that never changes.
- Uniform casing on HTTP/1.1. Chrome mixes Title-Case standard headers with lowercase client hints; all-lowercase and all-Title-Case are both wrong.
- Stale values. A document Accept without
image/jxlno longer matches Chrome 156.
Libraries that impersonate TLS and HTTP/2 well, such as curl_cffi, usually leave per-request headers to the caller. Matching a browser means a table per request kind that tracks each Chrome release, not one profile. A real browser gets this right without a table because it builds the headers itself, which is why a managed web data API that renders pages in a real browser does not need to maintain one.
