HTTP Content Negotiation: Accept Headers and How Servers Choose

Accept, Accept-Language, Accept-Encoding — how HTTP clients tell servers what they want, and how servers decide what to return.

HTTP Content Negotiation: Accept Headers and How Servers Choose

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

  • Accept tells the server what media types the client wants.
  • Accept-Language for language preference.
  • Accept-Encoding for compression (gzip, brotli).
  • Quality values (q=0.5) rank preferences.
  • Server picks the best match and returns it; 406 if nothing acceptable.
  • Vary header 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.

Get Started

Convert IPs into accurate location data in milliseconds.

Sign up today and get 1,000 free monthly stored conversions, and discover why developers trust us for fast, reliable, and affordable IP conversions.