Cache Headers

Cache Headers and Asset Fingerprinting

The default Cache-Control: public, max-age=0 is deliberately timid: every asset is cached but revalidated on every page load, a round trip per file. The fix is not a longer max-age on everything but a split into two populations. Files whose name never changes with their content (/logo.png, /index.html) must stay revalidated, because you cannot tell a browser the old copy is stale. Files whose name contains a hash of their content (/assets/app.4f1c9d2e.js) cannot go stale, because new content means a new URL — fingerprinting, which every bundler emits by default.

Two static roots, two caching policies
Two static roots, two caching policies

The immutable directive makes the second row work: it tells the browser not to revalidate even on reload.

Output of 26
$ curl -s -D - -o /dev/null http://127.0.0.1:4187/assets/app.4f1c9d2e.js
Cache-Control: public, max-age=31536000, immutable
$ curl -s -D - -o /dev/null -H 'If-None-Match: W/"1f-1a0b0601b6e"' .../css/site.css
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=0

For the revalidated population the ETag earns its keep: send it back as If-None-Match and the body is skipped. Express 24,430 computes weak ETags from size and mtime rather than a digest of the contents, so a file rebuilt byte-for-byte identically still gets a new tag. When one root holds both populations, pass a setHeaders(res, path) hook instead of a flat maxAge and set Cache-Control from a filename test such as /\.[0-9a-f]{8,}\.(js|css|woff2)$/.