Content negotiation and the Accept headers
Content negotiation is how one URL can return JSON to an API client, HTML to a browser, and a gzipped body to a client that asked for compression, all without changing the address. The client states what it can accept, the server picks the best match it has, and a single header keeps caches honest about the result.
The request side: Accept headers
The client sends one or more Accept family headers describing what it would like back. Each covers a different dimension of the response.
| Header | Negotiates | Example value |
|---|---|---|
Accept | media type | application/json, text/html |
Accept-Language | natural language | en-US, fr |
Accept-Encoding | compression | br, gzip |
Accept-Charset | character set (now rarely used) | utf-8 |
A browser's default Accept is a long list because it can render many formats. An API client typically sends a narrow one such as Accept: application/json to say it wants structured data and nothing else.
Ranking with q-values
When a client lists several options, it ranks them with a quality value, q, between 0 and 1. A missing q defaults to 1, the highest. Lower numbers mean "acceptable but less preferred."
Accept-Language: en-US, en;q=0.9, fr;q=0.5, *;q=0.1
This reads as: ideally US English, otherwise any English at 0.9, otherwise French at 0.5, and as a last resort anything at all at 0.1. A q=0 means the client explicitly refuses that option. The server reads the list, compares it against what it can produce, and serves the acceptable option with the highest quality value.
How the server chooses
The server intersects the client's ranked preferences with its own available representations, then picks the best mutual match. If it has English and German versions and the client asked for French first and English second, it serves English, because that is the highest ranked option both sides support. If nothing the client lists is available, the server may return 406 Not Acceptable, though in practice many servers fall back to a sensible default rather than refuse outright, since a blank response rarely helps the user.
The Vary header ties it together
Here is the part that breaks caches when forgotten. If one URL can return different bodies depending on request headers, a shared cache must not serve an English copy to a client that asked for French. The server signals this with Vary, listing the request headers that influenced the choice:
Vary: Accept-Encoding, Accept-Language
This tells every cache along the path to key stored responses not just on the URL but on those header values too. Omit Vary and a CDN will happily serve the first variant it cached to everyone, so a French user receives the English page and a client that cannot decompress brotli receives a brotli body. A missing or incomplete Vary is one of the most common causes of "the wrong language keeps showing up" bugs behind a CDN.
Practical notes
- Always send
Vary: Accept-Encodingwhen you compress conditionally, or a cache may hand a compressed body to a client that did not ask for one. - For languages, a
Content-Languageresponse header tells the client what it actually got, which is useful when the server fell back. - Over negotiating is a real cost. Each dimension in
Varymultiplies the number of cache entries for one URL, so negotiate only on what genuinely varies.
To see the Accept values a client sends and the Vary and Content-* headers a server returns, the HTTP Header Checker shows the full exchange for any URL.