Content Negotiation
One URL can serve JSON or HTML, English or Russian, gzip or brotli. Work through Accept headers with quality values and specificity, learn when to return 406, and make caches safe with Vary.
🎯 What you'll learn
- One URL, multiple representations
- Accept, q-values and specificity
- Accept-Language and Accept-Encoding
- Server- vs agent-driven negotiation, Vary and 406
📚 Before you start
1The Problem: One URL, Multiple Representations
The same resource — /users/42 — might reasonably be served as JSON or XML, in English or Japanese, compressed with gzip or not at all. Content negotiation is the mechanism by which a client tells the server what it prefers and the server picks the best available match, all without needing a different URL per variant (/users/42.json, /users/42.xml, ... would work too, but couples the representation into the identifier itself).
2Accept: Negotiating Media Type
Accept: application/json, text/html;q=0.8, */*;q=0.1Each entry is a media type (or a wildcard) with an optional q (quality) value from 0 to 1, defaulting to 1 when omitted. Higher q means stronger preference. This example says: "JSON is what I really want; plain HTML is acceptable but I'd rather not; and if you have to, anything at all beats nothing."
# Server picks JSON, and MUST say so explicitly
HTTP/1.1 200 OK
Content-Type: application/json
Vary: Accept3Specificity Beats q When Comparing Different Patterns
Accept: text/*;q=0.9, text/html;q=0.8This looks like text/* should win (higher q), but specificity is evaluated before q is used as a tiebreaker: an exact type/subtype match outranks a type/* wildcard, which outranks a bare */*, regardless of the numeric q attached to each. q only breaks ties within the same specificity tier. So here, if the server can produce text/html, it should send text/html — the more specific pattern the client listed wins the match even though its stated q is numerically lower.
Specificity order (most to least specific):
type/subtype > type/* > */*4q=0 Means "Absolutely Not," Not "Neutral"
Accept: application/xml;q=0, */*A q of exactly 0 is a hard exclusion — "I can technically parse other things, but never send me XML." This is different from a media type being absent from the header, which just means "no stated preference either way, treated per the wildcard rules." Servers that only check "is q low" instead of "is q exactly zero" can accidentally serve an explicitly-rejected type.
5Accept-Language and Accept-Encoding: the Same Pattern, Different Axis
Accept-Language: en-US, en;q=0.9, fr;q=0.5
Accept-Encoding: br, gzip;q=0.8, *;q=0.1Same q-weighted list syntax, different negotiation axis. Accept-Language uses language ranges (en-US is more specific than en, which is more specific than *), following the same specificity-before-q rule as section 3. Accept-Encoding negotiates compression and is covered from the caching side (including why Vary: Accept-Encoding matters for shared caches) in networking-web/http/05-caching_and_compression — this topic covers the negotiation algorithm itself; that one covers its caching consequences.
6Server-Driven vs Agent-Driven Negotiation
Server-driven (the common case): the client sends Accept* headers, the server picks unilaterally and returns one representation. Simple, one round trip, but the server's algorithm might not match what the client would have actually chosen.
Agent-driven: the server returns a 300 Multiple Choices (rare in practice) listing the available variants, and the client picks explicitly with a follow-up request. Almost never used in real APIs — the extra round trip defeats the point for most use cases — but it exists in the spec for cases where server-side heuristics genuinely can't guess correctly.
7Vary: Telling Caches What Changed the Answer
HTTP/1.1 200 OK
Content-Type: application/json
Content-Language: en-US
Vary: Accept, Accept-LanguageVary lists every request header that influenced this particular response. A cache must treat requests with different values of a Vary-listed header as needing separate cache entries — a French Accept-Language request must never be served the cached English response, even for the identical URL. Omitting a header from Vary that actually affected the response is a real bug class: users intermittently get a response negotiated for someone else's request.
8406 Not Acceptable
HTTP/1.1 406 Not Acceptable
Content-Type: text/plain
No representation matching the requested Accept: application/xml is available.Rarely used in practice — most real APIs ignore an unsatisfiable Accept and return their default representation anyway rather than failing the request outright, since a strict 406 breaks more clients than it helps (many HTTP clients send an Accept header they don't actually mean literally). It exists for cases where returning the wrong representation would be actively wrong (e.g. a strict XML-only integration).
Under the HoodHow it works internally▾
The real matching algorithm is intersection, not just sorting the client's list
A naive implementation sorts the client's Accept entries by q and picks the first one the server "supports" by name. That's wrong whenever wildcards are involved, because the server also has to test which of the representations it can actually produce against which client pattern matches each one, then rank the matches — not rank the client's raw list in isolation. Concretely: given available representations [application/json, text/html] and Accept: text/*;q=0.9, application/json;q=0.7, the server must compute, for each representation, the best matching pattern from the client's list (text/html matches text/* at 0.9; application/json matches its exact entry at 0.7), and only then pick the representation with the highest resulting score — text/html, even though application/json was listed with an exact match and looks superficially more "specific" in the request. The specificity rule (section 3) applies per-representation, not globally across the whole header.
Why q=0 has to be checked as its own branch, not folded into a sort
If matching code just sorts candidates by score descending and takes the top one, a q=0 entry that happens to be the only matching pattern for a representation will still let that representation through with a very low but non-negative score, silently violating the client's explicit refusal. Correct negotiation code treats q=0 (or a representation matching no pattern at all when the header omits * implicitly disallowing anything unlisted) as elimination, applied before ranking survivors — a filter step, not just a sort key.
Vary interacts with negotiation the same way it interacts with compression
Every representation a server could have chosen instead is exactly the set of things Vary needs to name. This is why Vary is naturally derived from "which request headers did my negotiation algorithm actually consult," rather than a value someone hand-picks once — if a server adds a new negotiated dimension (say, starts honoring Accept-Charset) and forgets to add it to Vary, caches will keep serving stale negotiated results indefinitely, exactly as described for compression in networking-web/http/05-caching_and_compression.
⚠️ Common Pitfalls
- Sorting by
qbefore applying specificity. Produces wrong results whenever a wildcard has a higherqthan an exact match that's also present — see section 3 and the Under the Hood algorithm. - Treating
q=0the same as "not mentioned."q=0is an explicit veto; an unmentioned type falls back to wildcard rules (which may still allow it) — these are different outcomes. - Forgetting
Varyentirely, or listing only some of the headers actually consulted. Both cause a cache to serve one user's negotiated response to another user expecting a different one. - Trusting
Acceptheaders literally when clients often send a broad, non-committal list. Many real clients (browsers especially) sendAccept: */*or a broad boilerplate list without meaning every entry seriously; defaulting to a sane representation on ambiguity is usually better than a strict406. - Re-implementing
q-value parsing withparseFloatand no bounds checking. A malformed or out-of-rangeq(negative,>1, non-numeric) should degrade gracefully (commonly treated asq=1or the entry ignored), not throw or silently corrupt the ranking of other entries. - Assuming content negotiation and CORS interact. They don't —
Accept/Varynegotiation happens regardless of origin; CORS is a separate, unrelated gate on whether the requesting page's JS may read the result at all (seenetworking-web/browser-security/02-cors).
📋 Quick Reference
Accept: type/subtype;q=N, type/*;q=N, */*;q=N -- q defaults to 1, range [0,1]
Specificity order (always applied BEFORE q as tiebreaker):
type/subtype > type/* > */*
q=0 -> explicit "never send me this" -- NOT the same as unmentioned
Accept-Language -- same q-weighted syntax, language-range specificity (en-US > en > *)
Accept-Encoding -- same q-weighted syntax, see caching topic for Vary consequences
Server-driven negotiation -- server decides unilaterally (the common case, one round trip)
Agent-driven negotiation -- server offers a 300 Multiple Choices list (rare in practice)
Vary: <headers> -- MUST list every request header that changed this response;
a cache keys separately per distinct value of each listed header
406 Not Acceptable -- rarely used; most APIs serve a default instead of hard-failing🧠 Key takeaways
- Clients state preferences with Accept\* headers and q-values (0–1; q=0 means 'never').
- More specific media ranges beat wildcards regardless of order.
- Servers declare their choice (
Content-Type,Content-Language,Content-Encoding) and add Vary. - Return 406 when nothing acceptable exists (or a sensible default, documented).
🎤 Interview Q&A
Q1What is content negotiation?core▾
The client states preferences via Accept, Accept-Language, Accept-Encoding, and the server picks the best representation (e.g. JSON vs XML, English vs French, gzip vs Brotli), reporting the choice in Content-Type, Content-Language, Content-Encoding and Vary.
- What if nothing matches? 406 Not Acceptable, or a default representation.
Q2What are quality values (q-values)?core▾
Weights in Accept headers: Accept-Language: fr-CH, fr;q=0.9, en;q=0.8 means prefer Swiss French, then French, then English.
- Default q? 1.0.
Q3How can content negotiation be used for API versioning?senior▾
Via vendor media types: Accept: application/vnd.myapi.v2+json. It keeps URLs stable but is less visible and harder to test in a browser than URL versioning.
- Who uses it? GitHub's API historically.
Q4What's the difference between Content-Type and Accept?core▾
Content-Type describes the body being sent (in requests or responses); Accept states what the client wants back. Sending JSON with the wrong Content-Type often results in 415 Unsupported Media Type.
- 415 vs 406? 415: server can't handle the request body type; 406: server can't produce an acceptable response.
Q5Why must servers send Vary when negotiating?core▾
So caches store variants separately per negotiated header; otherwise a cache may serve a French page to English users or Brotli to clients that don't support it.
- Downside of Vary: Accept? Accept headers vary widely, fragmenting caches; normalize them at the CDN.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.