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.

Last Updated:

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):

  1. Exact match (application/json) beats wildcard subtype (application/*)
  2. Wildcard subtype (application/*) beats full wildcard (*/*)
  3. Among equal specificity, higher q value wins
  4. 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