Content Negotiation & HTTP Caching
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the
minimum for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. Everything was checked against
the official documentation on 2026-10-29. It covers two halves of the same HTTP conversation. Content negotiation lets
one URL return different representations (JSON, HTML, plain text, another language) depending on what the client says it
accepts. HTTP caching lets clients, browsers, reverse proxies and CDNs reuse a representation instead of asking for it
again, and lets a server answer "nothing changed" with an empty 304 Not Modified. The two meet in the Vary header,
which tells caches which request headers took part in choosing a representation. The examples belong to the Bookshelf
API that grows across this section, use ESM and the node: prefix for built-in modules, and contain placeholders only,
never real secrets.
Content negotiation
Negotiation is driven by request headers: Accept (media types), Accept-Language, Accept-Encoding and
Accept-Charset. Each header is a comma-separated list of preferences, optionally weighted with a quality value, so
text/html, application/json;q=0.9, */*;q=0.1 means "HTML if you can, otherwise JSON, otherwise anything". Express
does not implement the matching itself: it delegates to the accepts package, which in turn uses negotiator, so the
behaviour is the one defined by HTTP semantics (RFC 9110) and is the same in Express 4 and Express 5.
The req.accepts* family
| Method | Returns | Notes |
|---|---|---|
|
The best matching entry of |
|
|
The best matching language tag, or |
Matches against |
|
The best matching content coding, or |
Matches against |
|
The best matching charset, or |
Matches against |
Server order matters when the client does not rank the options: the order of the array you pass to req.accepts() is
the tie-breaker, so list the representation you prefer to serve first. When a quality value breaks the tie, the client
wins. req.accepts() returns the entry exactly as you wrote it, which makes it convenient in a switch statement.
The singular forms req.acceptsCharset(), req.acceptsEncoding() and req.acceptsLanguage() were deprecated in
Express 4 and removed in Express 5. Use the plural methods above; the official codemod recipe pluralize-method-names
rewrites calls automatically.
res.format()
res.format(object) performs the negotiation and the dispatch in one call. The keys of the object are media types or
extensions, the values are functions called with (req, res, next), and Express picks the key that best matches the
Accept header using req.accepts(). It also sets Vary: Accept on the response, so downstream caches know the answer
depends on that header.
Two special cases are worth knowing:
-
A key named
defaultruns when nothing matches, which is how you serve a fallback (typically the primary format) instead of an error. -
Without a
defaultkey, an unmatched request produces an error withstatus406 and atypesproperty listing the supported types, passed tonext(err). Your error-handling middleware (see Error Handling) turns it into the response; the built-in handler sends a plain406 Not Acceptable.
Because res.format() calls your function, that function must send the response itself; returning a value does nothing.
The first example puts both tools to work and adds a language-aware message.
Complete example: one resource, several representations
// negotiation.mjs
import express from 'express';
const app = express();
const books = [
{ id: '1', title: 'Dune', author: 'Frank Herbert' },
{ id: '2', title: 'Neuromancer', author: 'William Gibson' },
];
const escapeHtml = (value) =>
String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
// Representations are small functions so that routes stay readable.
const asText = (list) => list.map((b) => `${b.id}. ${b.title} - ${b.author}`).join('\n');
const asHtml = (list) =>
`<!doctype html><meta charset="utf-8"><ul>${list
.map((b) => `<li>${escapeHtml(b.title)} (${escapeHtml(b.author)})</li>`)
.join('')}</ul>`;
const asCsv = (list) =>
['id,title,author', ...list.map((b) => [b.id, b.title, b.author].map((v) => JSON.stringify(v)).join(','))].join('\n');
// 1. res.format: the key order is the server preference when the client has none.
app.get('/books', (req, res) => {
res.format({
json() {
res.json(books);
},
html() {
res.type('html').send(asHtml(books));
},
text() {
res.type('text').send(asText(books));
},
'text/csv': function csv() {
res.type('text/csv').send(asCsv(books));
},
default() {
// Clients that sent an Accept header we cannot satisfy get a 406 with a JSON body listing the supported types.
res.status(406).json({ title: 'Not Acceptable', status: 406, supported: ['json', 'html', 'text', 'text/csv'] });
},
});
});
// 2. req.accepts: negotiate by hand when the choice affects more than the body.
app.get('/books/:id', (req, res, next) => {
const book = books.find((b) => b.id === req.params.id);
if (!book) {
return res.status(404).json({ title: 'Not Found', status: 404 });
}
switch (req.accepts(['json', 'html', 'text'])) {
case 'json':
res.vary('Accept'); // req.accepts does not set Vary for you, res.format does
return res.json(book);
case 'html':
res.vary('Accept');
return res.type('html').send(asHtml([book]));
case 'text':
res.vary('Accept');
return res.type('text').send(asText([book]));
default:
return next(Object.assign(new Error('No acceptable representation'), { status: 406 }));
}
});
// 3. Language negotiation with a safe default.
const greetings = { en: 'Welcome to the Bookshelf', es: 'Bienvenido a la Bookshelf', fr: 'Bienvenue a la Bookshelf' };
app.get('/welcome', (req, res) => {
const lang = req.acceptsLanguages(Object.keys(greetings)) || 'en';
res.vary('Accept-Language');
res.set('Content-Language', lang);
res.type('text').send(greetings[lang]);
});
app.use((err, req, res, next) => {
if (res.headersSent) {
return next(err);
}
const status = err.status ?? 500;
res.status(status).json({ title: status === 500 ? 'Internal Server Error' : err.message, status });
});
app.listen(3000, (error) => {
if (error) {
throw error;
}
console.log('Bookshelf listening on http://localhost:3000');
});
Try it with curl -H 'Accept: text/csv' http://localhost:3000/books, then with Accept: application/xml to see the
fallback, and with curl -H 'Accept-Language: es-ES,es;q=0.9' http://localhost:3000/welcome.
Design guidance that follows from the example:
-
Negotiating on the URL as well as on the header is perfectly valid. Many APIs support
/books.csvor a?format=csvquery for browsers, bookmarks and tools that cannot set headers, and negotiate only when no extension is present. Keep one canonical URL per representation so caches and links stay predictable. -
Decide between a
defaultfallback and a406deliberately. The HTTP specification allows either; an API that is strict about formats usually prefers406or415(for request bodies), while a website prefers to serve something. -
A client that sends
*/*or noAcceptat all (mostcurlcalls, many HTTP libraries) takes the first entry in the list. Put JSON first for an API and HTML first for a website. -
Do not negotiate on the
User-Agentheader, and avoid negotiating on request headers that vary per client (cookies, IP address) unless you also mark the response as private andVaryon them.
The Vary header
A cache stores a response keyed by its URL (and method). If the server’s answer also depended on a request header, the
cache must know, or it will hand a JSON body to the next client that asked for HTML. Vary lists those request headers:
Vary: Accept, Accept-Language tells every cache between the server and the client to store a separate copy per
combination of values of those headers. A missing Vary is a classic cache-poisoning bug, and Vary: * effectively
disables shared caching.
Express offers three ways to maintain it:
-
res.format()setsVary: Acceptautomatically. -
res.vary(field)appends a field to the header without duplicating existing entries (it accepts a string or an array). In Express 5 callingres.vary()without a field throws; Express 4 ignored the call. -
Middleware sets it for you:
compressionaddsVary: Accept-Encoding, andcorsaddsVary: Originwhenever the allowed origin depends on the request. See Official Middleware Modules.
Rules of thumb: add Accept-Encoding if you compress, Origin if CORS echoes the origin, Accept and
Accept-Language if you negotiate on them, and Cookie (or better, Cache-Control: private) when the response is
personalised. Every field you list multiplies the number of cached copies, so negotiate on as few headers as possible.
How HTTP caching works
HTTP caching has two independent mechanisms that are normally combined:
| Mechanism | Question it answers | Headers |
|---|---|---|
Freshness |
May I reuse the stored response without asking the server at all? |
|
Validation |
I have a stored copy that may be stale. Is it still current? |
Validators sent by the server: |
A response with a freshness lifetime is served from the cache with no network traffic until it expires. After that, the
cache sends a conditional request carrying the validators it remembers. If the resource is unchanged the server answers
304 Not Modified with no body, and the cache refreshes the stored copy; otherwise it sends the new representation. The
sequence is below. The semantics are defined in RFC 9110 (conditional requests) and RFC 9111 (caching).
An important consequence appears in steps 6 and 7: for a dynamic route, a 304 saves bandwidth, not server work. The
handler still runs and res.send() still renders the body, because Express has to know the new ETag to compare it. If
the expensive part is loading data, check a cheap validator (a version column, an update timestamp) before loading and
answer 304 yourself, as in Complete example: conditional GET, Last-Modified and optimistic concurrency.
ETags and the etag setting
An ETag is an opaque identifier for one version of a representation. When a handler finishes with res.send(),
res.json() or res.jsonp(), and the response has a body and no ETag header yet, Express computes one from the body
and sets the header. It also checks the request, as described in req.fresh and req.stale, and turns the response into 304 when
the client’s copy is current.
The application setting etag controls the generation:
| Value | Effect |
|---|---|
|
Weak ETag: the header looks like |
|
Strong ETag: a quoted hash with no |
|
Express does not generate ETags. Use this when you manage validators yourself or when hashing large bodies is too costly. |
A function |
Full control: the function receives the body as a |
Details that surprise people:
-
The default hash covers the serialised body, so any change in bytes (including JSON key order) changes the tag.
-
app.set('etag', ...)also compiles theetag fnsetting used byres.send(); set the option once, at startup. -
The tag is generated per
res.send()call, so it applies equally to HTML, JSON and text. Streams andres.write()/res.end()output are not hashed, because the body is never available as a whole; set the header yourself. -
Static files get their own validators from the
sendpackage, based on file size and modification time; see Caching of static files andres.sendFile(). -
Compression changes bytes. A strong ETag on an uncompressed body must not be reused verbatim for the gzipped one, which is a reason the default is weak. When a reverse proxy compresses responses for you, confirm that it keeps or weakens the tag consistently.
-
A hash-based ETag does not make the handler cheaper (see the previous section). To save database work, use a custom validator.
req.fresh and req.stale
req.fresh is true when the client’s cached copy is still valid according to the validators of the response being
built, and req.stale is its inverse. Both are getters that read the request’s If-None-Match and If-Modified-Since
headers and the response’s ETag and Last-Modified headers, using the fresh package. They are meaningful only for
GET and HEAD requests (Express 5.3 extends the same check to the HTTP QUERY method, see its entry in
History.md) and only after the response validators are set. They return false when the request carried
Cache-Control: no-cache (a forced refresh) or when the status is not a 2xx or 304.
Inside res.send(), Express does the check for you: once the ETag exists it tests req.fresh, and if it is true it
sets the status to 304 and strips the entity headers (Content-Type, Content-Length, Transfer-Encoding) and the
body. This is why a plain res.json(value) already gives you conditional-GET support. Use the properties directly when
you want to short-circuit earlier:
res.set({ ETag: etag, 'Last-Modified': updatedAt.toUTCString() });
if (req.fresh) {
return res.status(304).end();
}
The comparison rules follow HTTP: If-None-Match uses weak comparison, so W/"abc" and "abc" match; a list of tags or
* is allowed; and when If-None-Match is present, If-Modified-Since is ignored.
Complete example: conditional GET, Last-Modified and optimistic concurrency
The following program serves a book with a cheap validator derived from its version number, answers 304 before touching
the data store, sets Cache-Control for the representation and uses If-Match to reject lost updates with 412
(precondition failed). Express handles If-None-Match and If-Modified-Since for you through req.fresh; it does not
evaluate If-Match or If-Unmodified-Since on dynamic routes, so the handler must.
// conditional.mjs
import express from 'express';
const app = express();
app.set('etag', false); // we generate validators ourselves
app.use(express.json());
// A stand-in for a database: every row carries a version and an update time.
const store = new Map([['1', { id: '1', title: 'Dune', version: 1, updatedAt: new Date('2026-01-15T10:00:00Z') }]]);
const etagOf = (book) => `"book-${book.id}-v${book.version}"`; // strong: version fully identifies the bytes
// Parses an If-Match or If-None-Match header into a list of tags (or the wildcard).
function tagsOf(header) {
if (!header) {
return null;
}
return header.trim() === '*' ? ['*'] : header.split(',').map((tag) => tag.trim());
}
app.get('/books/:id', (req, res) => {
const book = store.get(req.params.id);
if (!book) {
return res.status(404).json({ title: 'Not Found', status: 404 });
}
// Validators and caching policy first, so that 200 and 304 carry the same headers.
res.set({
ETag: etagOf(book),
'Last-Modified': book.updatedAt.toUTCString(),
'Cache-Control': 'private, max-age=30, stale-while-revalidate=60',
});
res.vary('Accept');
// req.fresh compares If-None-Match / If-Modified-Since with the headers above.
// We have not loaded anything expensive yet, so a 304 costs almost nothing.
if (req.fresh) {
return res.status(304).end();
}
res.json(book);
});
app.put('/books/:id', (req, res) => {
const book = store.get(req.params.id);
if (!book) {
return res.status(404).json({ title: 'Not Found', status: 404 });
}
// Optimistic concurrency: the client must say which version it edited.
const ifMatch = tagsOf(req.get('If-Match'));
if (!ifMatch) {
return res.status(428).json({ title: 'Precondition Required', status: 428, detail: 'Send If-Match with the ETag you read.' });
}
if (!ifMatch.includes('*') && !ifMatch.includes(etagOf(book))) {
return res.status(412).json({ title: 'Precondition Failed', status: 412, detail: 'The book changed since you read it.' });
}
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (!title) {
return res.status(400).json({ title: 'Bad Request', status: 400, detail: 'A non-empty title is required.' });
}
const updated = { ...book, title, version: book.version + 1, updatedAt: new Date() };
store.set(book.id, updated);
res.set('ETag', etagOf(updated)).json(updated);
});
// Do not let a cache keep responses that carry credentials or personal data.
app.get('/me', (req, res) => {
res.set('Cache-Control', 'no-store').json({ name: 'demo' });
});
app.use((err, req, res, next) => {
if (res.headersSent) {
return next(err);
}
res.status(err.status ?? 500).json({ title: 'Internal Server Error', status: err.status ?? 500 });
});
app.listen(3000, (error) => {
if (error) {
throw error;
}
});
Source: Express API: Request (req.fresh, req.stale), Express API: Response (res.set, res.vary), RFC 9110: Conditional requests
Run it, then read the validator with curl -i http://localhost:3000/books/1, repeat the request with
curl -i -H 'If-None-Match: "book-1-v1"' http://localhost:3000/books/1 to get the empty 304, and send a PUT with a
stale If-Match to see 412. Note the Last-Modified timestamp has one-second precision, which is why a precise ETag
is the better validator when updates can happen several times per second.
Cache-Control for dynamic responses
Express adds no caching policy to dynamic responses, apart from validators. Without Cache-Control, browsers apply
heuristics (typically a fraction of the time since Last-Modified), which makes behaviour hard to predict, so set the
header explicitly with res.set('Cache-Control', ...) or a small middleware such as the cacheFor() factory in
Middleware Fundamentals. The directives that matter:
| Directive | Meaning |
|---|---|
|
Fresh for N seconds in any cache. |
|
Like |
|
A shared cache may store it even if the request had credentials. Needed rarely; shared caches already store plain GETs. |
|
Only the end user’s own cache may store it. Use it for anything personalised. |
|
Store it, but validate with the server before every reuse. Not the same as "do not cache". |
|
Do not store it anywhere. Use for tokens, account pages and anything sensitive. |
|
Once stale, never serve it without successful validation. |
|
Extensions from RFC 5861: serve stale content for N seconds while refreshing in the background, or when the origin fails. |
|
The content will never change during its freshness lifetime, so browsers skip revalidation on reload (RFC 8246). |
Choosing a policy per kind of response keeps things understandable:
-
Authenticated API responses and pages:
private, no-cache(orno-storefor secrets), with ETags. -
Public, slowly changing data:
public, max-age=60, stale-while-revalidate=300, with ETags. -
Fingerprinted assets (a hash in the file name):
public, max-age=31536000, immutable. -
The HTML shell that references those assets:
no-cache, so a new deployment is noticed at once. -
Error responses are cacheable by default for some status codes; send
no-storefor transient errors when a shared cache sits in front of the application. -
A reverse proxy or CDN may override or ignore these headers according to its own configuration, so test the full chain.
Caching of static files and res.sendFile()
express.static() and res.sendFile() are both built on the send package (Express 5: send 1.x, with serve-static
2.x), and they implement the whole conditional and range machinery without your help: ETag and Last-Modified from the
file’s size and modification time, If-None-Match, If-Modified-Since, If-Match, If-Unmodified-Since, If-Range
and Range. They answer 304, 412, 206 and 416 as appropriate. The options that control caching:
| Option | Default | Effect |
|---|---|---|
|
|
Sets |
|
|
Adds the |
|
|
Generates a weak ETag from the file’s size and mtime. Set to |
|
|
Sets |
|
|
When |
|
|
Advertises |
|
none |
A function |
A common production layout serves hashed assets with a very long lifetime and everything else with revalidation:
// static-caching.mjs
import path from 'node:path';
import express from 'express';
const app = express();
const publicDir = path.join(import.meta.dirname, 'public'); // Node.js 20.11+; use fileURLToPath on Node.js 18
app.use(
'/assets',
express.static(path.join(publicDir, 'assets'), {
maxAge: '365d',
immutable: true, // file names contain a content hash, so a changed file gets a new URL
index: false,
}),
);
app.use(
express.static(publicDir, {
maxAge: 0,
setHeaders(res, filePath) {
if (path.extname(filePath) === '.html') {
res.setHeader('Cache-Control', 'no-cache'); // always revalidate the HTML shell
}
},
}),
);
// res.sendFile honours the same options, and Range requests, for downloads.
app.get('/downloads/:name', (req, res, next) => {
const root = path.join(publicDir, 'downloads');
res.sendFile(req.params.name, { root, maxAge: '1h', dotfiles: 'deny', acceptRanges: true }, (error) => {
if (error) {
next(error); // ENOENT becomes a 404 via the error's status property
}
});
});
app.use((err, req, res, next) => {
if (res.headersSent) {
return next(err);
}
res.status(err.status ?? 500).type('text/plain').send(err.status === 404 ? 'Not found' : 'Server error');
});
app.listen(3000, (error) => {
if (error) {
throw error;
}
});
Source: Express: Serving static files, Express API: express.static, Express API: Response (res.sendFile)
Serving files is covered in more depth in Serving Static Files. Two caveats
apply here. In Express 5 the dotfiles option of express.static() defaults to ignore, which also hides
/.well-known, and the hidden and from options were removed. In production, a reverse proxy or CDN is usually a better
place to serve and cache static files; the headers set here tell it how.
Range requests and partial content
A Range header asks for part of a representation, used by video players, download managers and resumable downloads. The
static and sendFile machinery answers 206 Partial Content with a Content-Range header, 416 Range Not Satisfiable
for impossible ranges, and honours If-Range, which resumes only if the validator still matches (hence the preference for
strong validators on ranged resources).
For dynamic bodies, req.range(size, options) parses the header. It returns undefined when there is no Range
header, -1 when no range is satisfiable, -2 when the header is malformed, and otherwise an array of
{ start, end } objects (inclusive offsets) with a type property, normally bytes. Pass { combine: true } to merge
overlapping ranges. A minimal ranged response for an in-memory buffer:
// ranges.mjs
import express from 'express';
const app = express();
const payload = Buffer.from('The quick brown fox jumps over the lazy dog'.repeat(100));
app.get('/payload', (req, res) => {
res.set({ 'Accept-Ranges': 'bytes', 'Content-Type': 'application/octet-stream' });
const ranges = req.range(payload.length);
if (ranges === -1) {
return res.status(416).set('Content-Range', `bytes */${payload.length}`).end();
}
if (!ranges || ranges === -2 || ranges.type !== 'bytes' || ranges.length !== 1) {
return res.send(payload); // no, malformed or multi-range request: send the whole body
}
const { start, end } = ranges[0];
res.status(206).set('Content-Range', `bytes ${start}-${end}/${payload.length}`).send(payload.subarray(start, end + 1));
});
app.listen(3000, (error) => {
if (error) {
throw error;
}
});
Source: Express API: Request (req.range)
Real applications stream large files (res.sendFile() or fs.createReadStream()) instead of buffering them. Multiple
ranges require a multipart/byteranges response and are rarely worth supporting.
What changed recently
| Version | Relevant change |
|---|---|
Express 4 to 5.0.0 (2024-09) |
The singular |
Express 5.1.0 (2025-03-31) |
Express 5 became the default on npm. Dependency updates in |
Express 5.3.0 (2026-10-09) |
|
Content negotiation and the etag setting themselves behave the same in Express 4 and 5, so existing caching code
usually moves across unchanged; check your use of the removed acceptsLanguage() style methods and of hidden and
from options. The migration steps are in Migrating & What Changed.
Common pitfalls
| Symptom | Usual cause | Fix |
|---|---|---|
A browser shows JSON in place of the page, or the reverse, after visiting the other format. |
The response varies on |
Use |
Clients get |
|
Add |
|
ETag generation is disabled, the response is built from a stream, a middleware rewrites the body (and so the tag), or the
request lacks validators ( |
Check |
The ETag changes on every request. |
The body includes a timestamp, a random value or a request ID. |
Keep volatile fields out of the cached body or use a custom validator. |
Users see each other’s data. |
A shared cache stored a personalised response. |
Send |
A new deployment does not reach users. |
Long-lived caching of non-fingerprinted files, including the HTML shell. |
Fingerprint assets and send |
|
Express only evaluates |
Check |
|
|
Set a non-zero |
The proxy ignores your caching headers. |
The CDN or reverse proxy is configured to override |
Check its configuration and test through the full chain. |
Where the books are outdated
The four books consulted for this section target Express 4.0 to 4.18 and Node.js 0.10 to 20. They say little about negotiation and caching, but the practices below, common in code of that era, have changed.
| Older practice | Where it appears | Current behaviour |
|---|---|---|
Importing |
Bugl, chapter 7 |
Use |
Calling |
Express 3 and early Express 4 era code |
Removed in Express 5; use |
|
Express 3 and 4 era examples |
Removed in Express 5. Chain |
Treating |
Common source of mistakes in older tutorials |
The value is in milliseconds (or an |
Disabling or ignoring ETags and relying only on |
General Express 4 tutorials |
Weak ETags are on by default and combine well with |