When your browser asks for a page, it sends headers describing what it can accept: which formats, which languages, which encodings. The server uses these to decide what to return. This is HTTP content negotiation, and it’s how the same URL can return different content to different clients without changing the URL itself.
This post explains the negotiation headers, the patterns servers use, and the practical implementation in modern applications.
The Accept Headers
The main negotiation headers a client sends:
Accept
Which media types the client can handle:
Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8
Each item can have a q (quality) parameter from 0 to 1. The server picks the highest-quality acceptable format.
Accept-Language
Which languages the client prefers:
Accept-Language: en-US,en;q=0.9,de;q=0.8
Same q-value system. Higher = preferred.
Accept-Encoding
Which compression methods the client supports:
Accept-Encoding: gzip, deflate, br
Server picks one and applies before sending. Brotli (br) is the modern default; gzip is the universal fallback.
Accept-Charset (deprecated)
Historical; most modern clients accept UTF-8 by default. Not actively used.
How a Server Negotiates
The server compares the client’s Accept headers against what it can produce, and picks the best match.
Example: format negotiation
A REST API supports both JSON and XML. Client requests:
GET /users/123
Accept: application/json, application/xml;q=0.5
Server returns JSON (the higher quality match).
GET /users/123
Accept: application/xml
Server returns XML.
GET /users/123
Accept: text/csv
Server returns 406 Not Acceptable (can’t produce CSV).
Example: language negotiation
A multilingual site supports en and de. Client requests:
Accept-Language: en-US, en;q=0.9, de;q=0.8
Server returns English (the higher quality match).
Accept-Language: de
Server returns German.
Accept-Language: fr
Server returns default (usually English) since French isn’t available.
Implementation Patterns
Manual handling
Parse the Accept header, find the best match.
function negotiateContent(accept: string, available: string[]): string {
const parsed = accept.split(',').map(s => {
const [type, ...params] = s.trim().split(';')
const q = params.find(p => p.startsWith('q='))
const quality = q ? parseFloat(q.slice(2)) : 1
return { type, quality }
})
parsed.sort((a, b) => b.quality - a.quality)
for (const { type } of parsed) {
if (available.includes(type)) return type
if (type === '*/*') return available[0]
}
return '' // not acceptable
}
Library-based
Most frameworks have built-in helpers:
// Express
app.get('/users/:id', (req, res) => {
res.format({
'application/json': () => res.json(user),
'application/xml': () => res.send(toXml(user)),
default: () => res.status(406).send('Not Acceptable')
})
})
# FastAPI / Django
# Built-in content negotiation via Accept header parsing
Vary Header
If you serve different content based on Accept headers, you must tell caches:
Vary: Accept, Accept-Language, Accept-Encoding
This tells caches (browser, CDN) to key cached responses by those headers. Without it, a cache might serve English content to a German user (because the URL is the same).
For geo-redirects, the Vary header is similarly essential.
Common Use Cases
API format
REST APIs supporting both JSON and XML. JSON is the default; XML for legacy clients.
Localization
Multilingual sites returning content in the user’s preferred language.
Image formats
Browsers that support WebP get WebP; older browsers get JPEG. Done by checking Accept header server-side.
Accept: image/webp, image/avif, image/jpeg
Server checks for image/webp support and serves accordingly.
Compression
Most universal use: Accept-Encoding decides whether to send gzip/brotli or uncompressed.
Accept-Language for Localization
A common pattern: detect the user’s language from Accept-Language and serve localized content.
function pickLanguage(acceptLang: string, supported: string[]): string {
const preferences = acceptLang.split(',').map(s => {
const [lang, ...params] = s.trim().split(';')
const q = params.find(p => p.startsWith('q='))
const quality = q ? parseFloat(q.slice(2)) : 1
return { lang: lang.toLowerCase(), quality }
})
preferences.sort((a, b) => b.quality - a.quality)
for (const { lang } of preferences) {
// Exact match
if (supported.includes(lang)) return lang
// Language without region (en-US → en)
const base = lang.split('-')[0]
if (supported.includes(base)) return base
}
return supported[0] // default
}
Combining with geo
Some sites combine Accept-Language with geo detection:
- Accept-Language hints at the user’s preference.
- IP geo hints at their location.
- Combine for default region selection.
Always provide a manual override; auto-detection isn’t perfect.
When Negotiation Doesn’t Apply
A few cases where you skip negotiation:
URL-based variants
/en/about vs /de/about — separate URLs for separate languages. No negotiation needed; the URL itself indicates the variant. Better for SEO and bookmarking.
This is usually preferred over Accept-Language negotiation. Don’t make a multilingual site rely solely on Accept-Language; have explicit URLs.
File extensions
/users/123.json vs /users/123.xml — extension-based format selection. Sometimes used in REST APIs alongside (or instead of) Accept negotiation.
Query parameters
/users/123?format=json — query-string-based. Less clean than extensions or proper negotiation.
Quality Values in Detail
The q parameter takes a value from 0 (unacceptable) to 1 (most preferred). Default is 1 if omitted.
Accept: text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8
Interpretation:
- text/html — q=1.0 (preferred)
- application/xhtml+xml — q=1.0
- application/xml — q=0.9
- anything else — q=0.8
Server should pick text/html or xhtml+xml first.
Wildcards
Patterns like */* and image/*:
*/*— any media type.image/*— any image type.text/*— any text type.
Wildcards have lower priority than specific types. text/html;q=1.0, */*;q=0.5 prefers HTML over any other.
Content Negotiation and CDNs
Modern CDNs (Cloudflare, Fastly) understand content negotiation. They cache different variants for different Accept-Encoding values. Configure your CDN to respect Vary headers; it’ll cache gzipped, brotli, and uncompressed variants separately.
For format negotiation (JSON vs XML), CDN caching gets more complex. Usually simpler to use URL variants for cached endpoints.
CORS and Custom Accept
When making cross-origin requests with custom Accept headers, CORS preflight may kick in:
fetch('https://api.example.com/users/123', {
headers: { 'Accept': 'application/json' }
})
This is a simple request (Accept is on the safe list). But:
fetch('https://api.example.com/users/123', {
headers: { 'Accept': 'application/vnd.custom+json' }
})
Non-standard Accept value may trigger preflight. Configure your CORS to allow custom Accept values.
Server-Driven vs Client-Driven
Content negotiation as discussed here is server-driven: client sends preferences, server decides. There’s also client-driven negotiation (server lists alternatives, client picks) which is much less commonly implemented.
For practical purposes, “content negotiation” means server-driven.
TL;DR
Accepttells the server what media types the client wants.Accept-Languagefor language preference.Accept-Encodingfor compression (gzip, brotli).- Quality values (
q=0.5) rank preferences. - Server picks the best match and returns it; 406 if nothing acceptable.
Varyheader is essential when serving different content per Accept value.- URL-based variants (
/en/,/de/) often beat Accept-Language negotiation for multilingual sites. - Most frameworks have built-in negotiation helpers.
Content negotiation is invisible plumbing behind every modern HTTP exchange — gzip vs brotli is negotiated for nearly every page load. For deeper localization patterns, see geo-redirect implementation; for HTTP status codes that come up in negotiation (like 406), HTTP status codes reference.