Engineering Notes
Home/Networking & Web/HTTP/Content Negotiation
Networking & Web · HTTP

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.

🌐 Networking & Web● Intermediate⏱ 7 min read▶ 2 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 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

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

http
Accept: application/json, text/html;q=0.8, */*;q=0.1

Each 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."

http
# Server picks JSON, and MUST say so explicitly
HTTP/1.1 200 OK
Content-Type: application/json
Vary: Accept

3Specificity Beats q When Comparing Different Patterns

http
Accept: text/*;q=0.9, text/html;q=0.8

This 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.

text
Specificity order (most to least specific):
  type/subtype   >   type/*   >   */*

4q=0 Means "Absolutely Not," Not "Neutral"

http
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

http
Accept-Language: en-US, en;q=0.9, fr;q=0.5
Accept-Encoding: br, gzip;q=0.8, *;q=0.1

Same 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
HTTP/1.1 200 OK
Content-Type: application/json
Content-Language: en-US
Vary: Accept, Accept-Language

Vary 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
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).

Client lists ranked preferences; server intersects them against what it can actually produce, using specificity first and q-value only as a tiebreaker
Client lists ranked preferences; server intersects them against what it can actually produce, using specificity first and q-value only as a tiebreaker
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

  1. Sorting by q before applying specificity. Produces wrong results whenever a wildcard has a higher q than an exact match that's also present — see section 3 and the Under the Hood algorithm.
  2. Treating q=0 the same as "not mentioned." q=0 is an explicit veto; an unmentioned type falls back to wildcard rules (which may still allow it) — these are different outcomes.
  3. Forgetting Vary entirely, 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.
  4. Trusting Accept headers literally when clients often send a broad, non-committal list. Many real clients (browsers especially) send Accept: */* or a broad boilerplate list without meaning every entry seriously; defaulting to a sane representation on ambiguity is usually better than a strict 406.
  5. Re-implementing q-value parsing with parseFloat and no bounds checking. A malformed or out-of-range q (negative, >1, non-numeric) should degrade gracefully (commonly treated as q=1 or the entry ignored), not throw or silently corrupt the ranking of other entries.
  6. Assuming content negotiation and CORS interact. They don't — Accept/Vary negotiation happens regardless of origin; CORS is a separate, unrelated gate on whether the requesting page's JS may read the result at all (see networking-web/browser-security/02-cors).

📋 Quick Reference

text
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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

1. Accept: application/json;q=0 means…
q=0 excludes.
2. Why must the server send Vary: Accept?
Cache correctness.
3. Which status means no acceptable representation?
415 is for request bodies.
4. Accept: text/*;q=0.3, text/html → text/html gets q…
Specificity wins.