What HTTP/2 leaks
The moment an HTTP/2 connection opens, the client sends a SETTINGS frame — a short message declaring up to six configuration values. Each implementation picks its own defaults, so the SETTINGS frame acts like a name tag:
- Chrome 148 sends a specific, documented combination —
HEADER_TABLE_SIZE: 65536, ordered set of settings. - Python httpx sends
HEADER_TABLE_SIZE: 4096, noMAX_HEADER_LIST_SIZE, different ordering. - curl default has yet another signature.
- Go net/http has its own set.
The leak does not stop at SETTINGS. Other low-level details — the size of WINDOW_UPDATE frames (how much data the client says it is ready to receive), how HPACK header compression is applied, and the order in which streams are opened — form a secondary fingerprint. These are baked deep into the HTTP/2 client, so you cannot spoof them without rewriting the client itself.
The SETTINGS frame in detail
The SETTINGS frame is the first thing a client sends once the TLS handshake is done and the HTTP/2 preface (PRI * HTTP/2.0…SM, a fixed greeting that confirms both sides speak HTTP/2) has been exchanged. It lists the client's parameters as (id, value) pairs. Chrome 131 sends these six, in this exact order:
| Setting | Chrome value | Common scraper mismatch |
|---|---|---|
HEADER_TABLE_SIZE | 65536 | Go: 4096 (default) |
ENABLE_PUSH | 0 | Go: 1, httpx: 1 |
MAX_CONCURRENT_STREAMS | 1000 | httpx: 100 |
INITIAL_WINDOW_SIZE | 6291456 | Go: 65535, httpx: 65535 |
MAX_FRAME_SIZE | 16384 | — usually correct |
MAX_HEADER_LIST_SIZE | 262144 | Not sent by Go default |
Right after SETTINGS, Chrome sends a WINDOW_UPDATE frame with a delta of 15663105 (15 MB minus the default 65535) — its way of saying it can buffer a lot of incoming data. Then it sends the request headers in HPACK-compressed form, with the pseudo-headers (the leading :-prefixed fields) in the order :method, :authority, :scheme, :path — Firefox uses :method, :path, :authority, :scheme. Anti-bot vendors fingerprint all of it: the SETTINGS values, their order, the WINDOW_UPDATE delta, and the pseudo-header order. curl_cffi bakes Chrome's values in; tls-client and noble-tls require manual configuration.
Why this matters more than scrapers realise
Akamai's EdgeWorker checks JA4 (a fingerprint of the TLS handshake) at the TLS layer. The next layer down checks the HTTP/2 SETTINGS frame. If your JA4 says "Chrome 148" but your SETTINGS frame has HEADER_TABLE_SIZE: 4096 (the Python httpx default), Akamai sees the contradiction and assigns the maximum bot score before any HTML is served.
The mismatch is the signal. A clean JA4 with a Python HTTP/2 stack is worse than a clean JA4 with a Python TLS stack and HTTP/1.1 — the inconsistency itself is what gives you away.
What works
curl_cffi — wraps libcurl built against BoringSSL (Chrome's own TLS library) with Chrome's exact TLS and HTTP/2 parameters. One flag, impersonate="chrome131", handles both layers at once. This is the default first step for any Python scraper hitting a real anti-bot.
tls-client (Go, or a Python wrapper) — same idea, built in Go, and tends to be faster at 1k+ concurrent connections.
akamai-v3-sensor (Go) — for the hardest Akamai v3 targets that even curl_cffi cannot pass. Production scrapers run a small Go sidecar that uses a Chrome-compatible TLS + HTTP/2 stack at the C level, with Scrapy orchestrating the crawl.
Quick audit: request tls.browserleaks.com/json from your scraper and from real Chrome. The response includes the HTTP/2 SETTINGS — diff them. If they differ, your HTTP/2 layer is leaking.
