HTTP Content Negotiation in REST APIs
How clients and servers agree on response format using Accept headers — media types, quality factors, language negotiation, and compression. Defined in RFC 9110, Section 12.
What Is Content Negotiation?
Content negotiation is the mechanism by which an HTTP client and server agree on the format, language, and encoding of the response. The client advertises its capabilities and preferences via Accept headers; the server selects the best match and responds with the chosen format in the Content-Type header.
# Client requests JSON, but also accepts XML (lower priority)
GET /products/42 HTTP/1.1
Host: api.example.com
Accept: application/json, application/xml;q=0.9
Accept-Language: en-US, en;q=0.8, es;q=0.5
Accept-Encoding: gzip, br
# Server responds with best match
HTTP/1.1 200 OK
Content-Type: application/json
Content-Language: en-US
Content-Encoding: gzip
{ "id": 42, "name": "Widget" }
Server-Driven vs Client-Driven Negotiation
| Type | How It Works | Example |
|---|---|---|
| Server-driven (proactive) | Client sends preferences; server picks best representation | Accept: application/json |
| Client-driven (reactive) | Server returns 300 Multiple Choices listing available representations; client picks one | Rarely used in practice |
| Transparent | Proxy or CDN negotiates on behalf of client | CDN serving WebP vs PNG based on browser support |
Almost all REST APIs use server-driven negotiation — the client sends Accept headers, the server decides.
The Accept Header: Media Type Negotiation
The Accept request header tells the server what media type(s) the client can handle. Defined in RFC 9110 §12.5.1.
Quality Factors (q values)
Each media type can have a ;q= parameter (quality factor) from 0 to 1 indicating preference. Default is 1.0 (highest). q=0 means "do not send this".
# Prefers JSON, accepts XML as fallback, accepts anything as last resort
Accept: application/json, application/xml;q=0.9, */*;q=0.1
# Only accepts JSON — return 406 if unavailable
Accept: application/json
# Browser-style Accept (sent automatically by browsers)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
# Vendor-specific media types (JSON:API, HAL)
Accept: application/vnd.api+json
Accept: application/hal+json
Precedence Rules
When the server matches the Accept header against its available representations, specificity wins (RFC 9110 §12.5.1):
- Exact match (
application/json) beats wildcard subtype (application/*) - Wildcard subtype (
application/*) beats full wildcard (*/*) - Among equal specificity, higher q value wins
- Among equal specificity and q value, server preference applies
Node.js: Implementing Content Negotiation
const express = require('express');
const app = express();
app.get('/products/:id', async (req, res) => {
const product = await db.products.findById(req.params.id);
// Express req.accepts() parses the Accept header
const format = req.accepts(['json', 'xml', 'csv']);
switch (format) {
case 'json':
res.set('Content-Type', 'application/json');
return res.json(product);
case 'xml':
res.set('Content-Type', 'application/xml');
return res.send(toXml(product));
case 'csv':
res.set('Content-Type', 'text/csv');
return res.send(toCsv(product));
default:
// Cannot satisfy Accept header — return 406
return res.status(406).json({
error: 'Not Acceptable',
supported: ['application/json', 'application/xml', 'text/csv']
});
}
});
Accept-Language: Language Negotiation
The Accept-Language header lets clients request responses in a preferred language. Useful for internationalised APIs returning translated content, error messages, or date formats. Defined in RFC 9110 §12.5.4.
Accept-Language: en-US # US English only
Accept-Language: en-US, en;q=0.9 # US English preferred, then any English
Accept-Language: es, en;q=0.7 # Spanish preferred, English fallback
Accept-Language: * # Any language
The server should respond with the Content-Language header indicating the actual language of the response body:
Content-Language: en-US
Content-Language: es
Node.js: Language Negotiation
const i18n = {
'en': { greeting: 'Hello' },
'es': { greeting: 'Hola' },
'fr': { greeting: 'Bonjour' }
};
app.get('/greet', (req, res) => {
// req.acceptsLanguages() returns best match
const lang = req.acceptsLanguages(['en', 'es', 'fr']) || 'en';
res.set('Content-Language', lang);
res.json({ message: i18n[lang].greeting });
});
Practical note: Most REST APIs default to English and use the lang or locale query parameter instead (GET /messages?lang=es). This is less pure REST but more cache-friendly (different URLs = different cache keys).
Accept-Encoding: Compression Negotiation
The Accept-Encoding header lets the client advertise which compression algorithms it supports. The server compresses the response body accordingly, significantly reducing payload size for large JSON responses. Defined in RFC 9110 §12.5.3.
Accept-Encoding: gzip, deflate, br
Accept-Encoding: br;q=1.0, gzip;q=0.8 # Prefer Brotli, fall back to gzip
Accept-Encoding: identity # No compression (raw body)
| Algorithm | Token | Compression | Notes |
|---|---|---|---|
| Gzip | gzip |
~60-70% reduction | Universal support; fast |
| Brotli | br |
~15-25% better than gzip | Modern browsers/clients; HTTPS only in browsers |
| Deflate | deflate |
Similar to gzip | Inconsistent implementations; avoid |
| Identity | identity |
None | Explicit no-compression request |
Enabling Compression in Express
const compression = require('compression');
app.use(compression({
// Only compress responses larger than 1KB
threshold: 1024,
// Prefer Brotli for modern clients
brotli: { enabled: true, zlib: { level: 4 } },
filter: (req, res) => {
// Don't compress already-compressed or streaming responses
if (req.headers['x-no-compression']) return false;
return compression.filter(req, res);
}
}));
When to enable: Always enable compression for JSON APIs. A typical 50KB JSON response compresses to ~8KB with gzip. The CPU cost is negligible compared to the bandwidth and latency savings, especially on mobile networks.
API Versioning via Accept Header
Some APIs use the Accept header for versioning instead of URL paths. This keeps URLs clean but makes testing harder (you can't just paste a URL in a browser).
# GitHub API v3 style — vendor media type with version
Accept: application/vnd.github.v3+json
# Custom version parameter
Accept: application/json; version=2
# Full vendor type with version
Accept: application/vnd.myapi.v2+json
Node.js: Header-Based Versioning
app.get('/users/:id', (req, res, next) => {
const accept = req.headers['accept'] || 'application/json';
// Extract version from vendor media type
const v2Match = accept.match(/application\/vnd\.myapi\.(v\d+)\+json/);
const version = v2Match ? v2Match[1] : 'v1';
if (version === 'v2') {
return res.json(serializeUserV2(user));
}
return res.json(serializeUserV1(user));
});
For most APIs, URL versioning (/v2/users) is simpler, more cacheable, and easier to document. Use header versioning only if clean URLs are a strict requirement. See our Versioning Guide for a full comparison.
406 Not Acceptable: When Negotiation Fails
If the server cannot produce a representation satisfying the client's Accept headers, it must return 406 Not Acceptable. The response body (in the server's default format) should list the formats the server does support:
GET /products/42
Accept: application/msgpack # Server doesn't support this
HTTP/1.1 406 Not Acceptable
Content-Type: application/json
{
"error": "Not Acceptable",
"message": "Cannot serve the requested media type",
"supported_types": [
"application/json",
"application/xml",
"text/csv"
]
}
Practical caveat: Many APIs ignore the Accept header and always return JSON. This is pragmatic for single-format APIs but will cause issues if clients send strict Accept: application/xml and expect a 406 on unsupported formats. Document your behaviour explicitly.
Frequently Asked Questions
Should I always implement content negotiation?
For most REST APIs: no. If your API only serves JSON, just always return Content-Type: application/json. Implement content negotiation when you genuinely need to serve multiple formats (JSON, XML, CSV) or when you are building a versioned API that uses vendor media types.
What happens if a client sends no Accept header?
Per RFC 9110 §12.5.1, an absent Accept header is equivalent to Accept: */* — the client accepts any media type. The server should return its default format (typically JSON for REST APIs).
Is content negotiation the same as API versioning?
No, but they can be combined. Content negotiation selects the format of the response (JSON vs XML). Versioning selects the schema/structure of the response (v1 vs v2 fields). Some APIs combine both using vendor media types like application/vnd.myapi.v2+json, which simultaneously specifies the format (JSON), the vendor, and the version.
Does content negotiation affect HTTP caching?
Yes. If a resource can be served in multiple formats depending on the Accept header, the cache key must include the Accept header value — otherwise a cached JSON response could be served to a client that requested XML. The Vary: Accept response header tells caches to include the Accept value in the cache key:
Vary: Accept, Accept-Language, Accept-Encoding
Related Topics
HTTP Headers
Complete HTTP headers reference — request, response, security, and caching headers.
API Versioning
URL, header, and media-type versioning strategies compared.
Caching
Cache-Control, Vary header, and ETag strategies for REST APIs.
Error Handling
406, 415, and other content-related error responses.
Best Practices
REST API design best practices including response format decisions.
Design Guide
Complete REST API design guide from URLs to headers to pagination.