One URL can sometimes represent the same resource in several formats. An API might return JSON or CSV, while a documentation endpoint might return HTML or plain text.
HTTP content negotiation lets a client express which representation it can accept. The server chooses a response and tells caches which request headers influenced that choice.
The second part is easy to miss: if the response changes based on a request header, shared caches need the correct Vary metadata.
Negotiate representation with Accept
A client can send:
GET /report HTTP/1.1
Host: example.com
Accept: application/jsonThe server can respond:
HTTP/1.1 200 OK
Content-Type: application/json
Vary: Accept
{"status":"ready"}Another client might ask for CSV:
Accept: text/csvand receive:
Content-Type: text/csv
Vary: Accept
status
readyThe resource URL remains the same; the representation changes.
Content-Type and Accept have different directions
Accept describes what the requester is willing to receive.
Content-Type describes the media type of the message body that was actually sent.
For a JSON POST:
POST /items HTTP/1.1
Content-Type: application/json
Accept: application/jsonContent-Type refers to the request body. Accept refers to the desired response.
Servers should not treat them as interchangeable.
Parse preference, not just one exact string
Clients can send several media ranges with quality weights:
Accept: text/html, application/json;q=0.9, */*;q=0.1This means HTML is preferred, JSON is acceptable with lower preference, and other media types are a weak fallback.
Production frameworks often provide tested negotiation helpers. Hand-written parsers should account for media ranges, parameters, and quality values instead of splitting on commas and comparing raw strings.
If the server supports only one representation, negotiation may be unnecessary.
Return 406 Not Acceptable when appropriate
If the client explicitly asks for representations the server cannot produce:
Accept: application/xmla server may respond:
HTTP/1.1 406 Not AcceptableSome APIs instead fall back to a documented default. Whichever policy you choose, keep it consistent.
Do not claim a response has a media type the server did not actually produce.
Why Vary matters
Imagine a shared cache receives the JSON request first and stores the response.
If the response does not include:
Vary: Acceptthat cache might consider a later CSV request equivalent and serve the cached JSON response.
Vary: Accept tells compliant caches that the Accept request header is part of the cache key for that response.
This is a correctness requirement, not only a performance detail.
Vary on every header that changes the representation
If language selection depends on Accept-Language:
Vary: Accept-LanguageIf both media type and language change the representation:
Vary: Accept, Accept-LanguageDo not add headers to Vary casually. Every additional dimension can reduce cache reuse.
For example, Vary: User-Agent often fragments caches because user-agent strings have many values.
Do not use Vary to isolate private user data
Vary is not an authorization mechanism.
A response containing account-specific data should use appropriate cache controls, authentication, and private-cache policy. Do not attempt to make a shared cache safe merely by varying on an identity-related header without understanding the complete behavior of every intermediary.
For personalized responses, conservative directives such as:
Cache-Control: private, no-storemay be appropriate depending on the application.
Prefer explicit URLs when representations are separate products
Content negotiation is strongest when representations are truly alternate forms of the same resource.
If /report.csv is downloaded, documented, authorized, and cached differently from /report.json, separate URLs can be clearer.
Evaluate:
- client ergonomics;
- cache behavior;
- observability;
- linkability;
- documentation;
- authorization differences.
There is no requirement that every multi-format API use one URL.
Keep error representations consistent
Negotiation policy should include errors.
If a JSON API returns HTML error pages from a reverse proxy while successful responses are JSON, clients become harder to implement.
Gateways, application servers, and fallback handlers should agree on the media types exposed to API clients.
Test through a cache, not only against the origin
Origin tests can prove that the server returns JSON for one header and CSV for another. They do not prove that a CDN or reverse proxy caches them separately.
A useful integration test is:
- request JSON and allow it to cache;
- request CSV from the same URL;
- verify CSV is returned;
- reverse the order;
- inspect cache headers or cache logs.
Caching bugs often appear only after an intermediary is introduced.
Common mistakes
Looking at Content-Type to choose the response
For a GET without a request body, Content-Type may be absent. Response negotiation belongs to Accept.
Comparing Accept as one literal value
Real clients can send lists, wildcards, and quality weights.
Forgetting Vary
Shared caches can serve the wrong representation.
Varying on too many high-cardinality headers
That can destroy cache efficiency.
Negotiating personalized content through shared caches without a cache policy
Representation selection and authorization are separate concerns.
A practical design checklist
When one resource has several representations:
- define the supported media types;
- decide how
Acceptpreferences are resolved; - choose a fallback or
406policy; - return the actual
Content-Type; - add
Varyfor request headers that affect the response representation; - set explicit cache controls for private content;
- test behavior through the real CDN or proxy path.
Content negotiation is not just about returning the requested format. Correct HTTP behavior also tells intermediaries why that format was selected so they can cache it safely.