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

req.accepts(types)

The best matching entry of types, or false when none is acceptable.

types is a string, several arguments or an array. Entries may be full media types (application/json), extensions (json, html) or wildcards such as text/*. With no types, it returns the list of media types the client accepts, best first. A request without an Accept header accepts everything, so the first entry of types wins.

req.acceptsLanguages(langs)

The best matching language tag, or false.

Matches against Accept-Language. With no argument it returns the client’s list, best first.

req.acceptsEncodings(encodings)

The best matching content coding, or false.

Matches against Accept-Encoding (gzip, br, deflate, identity). Compression middleware already does this for you; call it yourself only for hand-made encodings.

req.acceptsCharsets(charsets)

The best matching charset, or false.

Matches against Accept-Charset, which modern clients rarely send. Prefer UTF-8 and ignore the header.

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 default runs when nothing matches, which is how you serve a fallback (typically the primary format) instead of an error.

  • Without a default key, an unmatched request produces an error with status 406 and a types property listing the supported types, passed to next(err). Your error-handling middleware (see Error Handling) turns it into the response; the built-in handler sends a plain 406 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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.csv or a ?format=csv query 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 default fallback and a 406 deliberately. The HTTP specification allows either; an API that is strict about formats usually prefers 406 or 415 (for request bodies), while a website prefers to serve something.

  • A client that sends */* or no Accept at all (most curl calls, 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-Agent header, and avoid negotiating on request headers that vary per client (cookies, IP address) unless you also mark the response as private and Vary on 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() sets Vary: Accept automatically.

  • res.vary(field) appends a field to the header without duplicating existing entries (it accepts a string or an array). In Express 5 calling res.vary() without a field throws; Express 4 ignored the call.

  • Middleware sets it for you: compression adds Vary: Accept-Encoding, and cors adds Vary: Origin whenever 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?

Cache-Control (max-age, s-maxage, no-store, no-cache, private, public, immutable) and the legacy Expires

Validation

I have a stored copy that may be stale. Is it still current?

Validators sent by the server: ETag, Last-Modified. Preconditions sent by the client: If-None-Match, If-Modified-Since, If-Match, If-Unmodified-Since, If-Range.

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

sequenceDiagram autonumber participant C as Client or cache participant E as Express participant H as Route handler C->>E: GET /books/1 E->>H: run handler H-->>E: res.json(book) E-->>C: 200 OK, ETag W/abc, Cache-Control max-age=60, body Note over C: Stored. Fresh for 60 seconds, then stale. C->>E: GET /books/1, If-None-Match W/abc E->>H: run handler H-->>E: res.json(book) Note over E: res.send compares the new ETag with If-None-Match, req.fresh is true E-->>C: 304 Not Modified, no body Note over C: Reuses the stored body and renews its freshness. C->>E: GET /books/1, If-None-Match W/abc Note over H: The book changed meanwhile E-->>C: 200 OK, ETag W/def, new body

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

true or 'weak' (default)

Weak ETag: the header looks like W/ followed by a quoted size and hash. A weak validator says "semantically equivalent", which is exactly right for dynamic responses that may be compressed or re-serialised in transit.

'strong'

Strong ETag: a quoted hash with no W/ prefix. Byte-for-byte identity, required for If-Range and for range requests against dynamic bodies.

false

Express does not generate ETags. Use this when you manage validators yourself or when hashing large bodies is too costly.

A function (body, encoding) => string

Full control: the function receives the body as a Buffer and the encoding, and returns the header value (quotes included), or a falsy value to skip the header. Used for example to derive the tag from a version number.

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 the etag fn setting used by res.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 and res.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 send package, based on file size and modification time; see Caching of static files and res.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;
  }
});

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

max-age=N

Fresh for N seconds in any cache. max-age=0 makes every reuse require validation.

s-maxage=N

Like max-age but only for shared caches (CDNs, proxies); takes precedence there.

public

A shared cache may store it even if the request had credentials. Needed rarely; shared caches already store plain GETs.

private

Only the end user’s own cache may store it. Use it for anything personalised.

no-cache

Store it, but validate with the server before every reuse. Not the same as "do not cache".

no-store

Do not store it anywhere. Use for tokens, account pages and anything sensitive.

must-revalidate

Once stale, never serve it without successful validation.

stale-while-revalidate=N, stale-if-error=N

Extensions from RFC 5861: serve stale content for N seconds while refreshing in the background, or when the origin fails.

immutable

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 (or no-store for 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-store for 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

maxAge

0

Sets max-age. A number is milliseconds, a string is parsed by the ms package ('1d', '2h'). express.static() and res.sendFile() therefore send Cache-Control: public, max-age=0 by default.

immutable

false

Adds the immutable directive. It only has an effect together with a non-zero maxAge.

etag

true

Generates a weak ETag from the file’s size and mtime. Set to false to disable. Note this is a per-middleware option, independent of the application’s etag setting.

lastModified

true

Sets Last-Modified from the file’s mtime.

cacheControl

true

When false, no Cache-Control header is sent, which lets you set it yourself (for example in setHeaders).

acceptRanges

true

Advertises Accept-Ranges: bytes and honours Range requests.

setHeaders (static only)

none

A function (res, path, stat) for per-file headers, for example a different policy for HTML than for images.

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;
  }
});

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;
  }
});

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 req.acceptsCharset(), req.acceptsEncoding() and req.acceptsLanguage() methods were removed in favour of the plural forms. res.vary() without an argument throws. res.status() accepts only integers from 100 to 999. express.static() hides dotfiles by default and no longer supports the removed hidden option; res.sendFile() lost from. Core MIME mappings changed (for example .js is served as text/javascript).

Express 5.1.0 (2025-03-31)

Express 5 became the default on npm. Dependency updates in send, serve-static and accepts carry the caching and negotiation behaviour described here.

Express 5.3.0 (2026-10-09)

req.fresh and req.stale also evaluate for the HTTP QUERY method registered with app.query(), as recorded in History.md.

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 Accept but has no Vary: Accept, so a cache reused the wrong copy.

Use res.format() or call res.vary('Accept').

Clients get 406 for a request that used to work.

res.format() has no default key and the client sent an Accept header that matches none of the keys.

Add default, or document the supported types.

304 responses never occur.

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 (Cache-Control: no-cache forces a full response).

Check etag, compare the request and response headers with curl -i, and confirm the same bytes are produced each time.

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 Cache-Control: private (or no-store), and Vary: Cookie or Authorization where applicable.

A new deployment does not reach users.

Long-lived caching of non-fingerprinted files, including the HTML shell.

Fingerprint assets and send no-cache for HTML.

If-Match is ignored and concurrent edits overwrite each other.

Express only evaluates If-None-Match and If-Modified-Since for dynamic routes.

Check If-Match yourself and return 412, as in Complete example: conditional GET, Last-Modified and optimistic concurrency.

immutable: true has no effect on express.static().

maxAge is 0.

Set a non-zero maxAge together with immutable.

The proxy ignores your caching headers.

The CDN or reverse proxy is configured to override Cache-Control.

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 serve-static directly without declaring it as a dependency.

Bugl, chapter 7

Use express.static(), which re-exports it; Express 5 uses serve-static 2.x and send 1.x.

Calling req.acceptsLanguage(), req.acceptsCharset() or req.acceptsEncoding().

Express 3 and early Express 4 era code

Removed in Express 5; use req.acceptsLanguages(), req.acceptsCharsets() and req.acceptsEncodings().

res.send(body, status), res.json(obj, status) or res.send(status) when answering with explicit status codes such as 304, 406 or 412.

Express 3 and 4 era examples

Removed in Express 5. Chain res.status(code).send(body), or use res.sendStatus(code).

Treating maxAge as seconds in express.static() and res.sendFile().

Common source of mistakes in older tutorials

The value is in milliseconds (or an ms string such as '1d').

Disabling or ignoring ETags and relying only on Last-Modified, or the reverse.

General Express 4 tutorials

Weak ETags are on by default and combine well with Last-Modified; set the etag option deliberately.