HTTP caching is the cheapest performance lever a website has: set correctly, it avoids entire requests and serves files from a point close to the visitor. Set poorly, it delivers stale content or, conversely, caches nothing at all. Three mechanisms cover almost every need: the Cache-Control header, revalidation through validators, and URL versioning. This guide ties them into one coherent strategy, from the browser to the CDN.
Two families of cache, one pivotal header
An HTTP cache falls into one of two categories. The private cache is the browser’s own, specific to a single visitor. The shared cache is the one every visitor passes through: a CDN, a reverse proxy such as Varnish, or an nginx server cache. The distinction is decisive, because a personalised response must never land in a shared cache, where it would be served again to a different visitor.
The header that drives all of this is Cache-Control, present in the server’s response. It supersedes the old Expires and Pragma, which no longer need to be emitted. It is read directive by directive, and a handful is enough to describe most policies. On a self-hosted server, these settings are configured once and for all in the web server itself.
The Cache-Control directives that matter
| Directive | Effect | Typical use |
|---|---|---|
max-age=N | Response fresh for N seconds | Default lifetime |
s-maxage=N | Like max-age, but for shared caches only | CDN more aggressive than the browser |
public | Response storable by a shared cache | Static files |
private | Storage restricted to the browser | Personalised page |
no-cache | Storable, but revalidated before each use | Frequently changing HTML |
no-store | Storage forbidden entirely | Sensitive data |
must-revalidate | Once stale, the response cannot be served without revalidation | Accuracy-critical content |
immutable | No revalidation while fresh, even on reload | Files with versioned URLs |
stale-while-revalidate=N | Serves the stale copy for up to N s while a new one loads in the background | Responses tolerating slight lag |
The most common confusion pits no-cache against no-store. The first allows storage but forces a revalidation every time; the second forbids storage altogether. For a dashboard showing confidential data, only no-store is appropriate.
Revalidation: ETag and Last-Modified
When a cached response expires, the cache does not need to download it again in full: it asks the server whether it has changed. That exchange rests on two validators. The server emits an ETag, an opaque fingerprint of the content, or a Last-Modified, a last-change date. On the next round, the cache returns these values in If-None-Match or If-Modified-Since.
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=0, must-revalidate
ETag: "9c1e-5f3a2b"
# Next request, once the response is stale:
GET /api/profile HTTP/1.1
If-None-Match: "9c1e-5f3a2b"
# The server replies with no body if nothing changed:
HTTP/1.1 304 Not Modified
ETag: "9c1e-5f3a2b"The 304 Not Modified response carries no body: only headers travel. On a large resource, the bandwidth saving is major, and perceived latency drops since the browser reuses its local copy. An ETag comes in a strong or weak form, the latter prefixed with W/ when semantic equivalence is enough, without requiring byte-for-byte identity.
A well-tuned cache is measured not by the number of files it stores, but by the number of requests it never had to handle.
Shared cache, private cache and the Vary header
A single URL can return variants: a gzip- or brotli-compressed version, a translation by language. The Vary header tells the shared cache which request headers distinguish those variants. Without it, a CDN risks serving the English version to a French-speaking visitor, or compressed content to a client that cannot decompress it.
# Versioned static files: long, immutable cache
location ~* \.(?:css|js|woff2|png|jpg|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# HTML document: always revalidated
location / {
add_header Cache-Control "no-cache";
add_header Vary "Accept-Encoding";
}The general rule fits in one sentence: long, immutable cache for files whose name carries a fingerprint, systematic revalidation for the HTML that references them. This split cleanly separates what changes from what never does. It matters for security too, since a mistyped authenticated response leaking into a shared cache is a vulnerability, and a minimal hardening baseline should include that check.
Invalidation: version the URL rather than purge
The famously hard problem of caching is invalidation. Purging a CDN on every deployment is slow and error-prone. The robust technique is to change a file’s URL when its content changes, by embedding a fingerprint: app.9f3c1a.js rather than app.js. The file then becomes eligible for a one-year max-age and immutable, since a new version will carry a different name. Modern bundlers generate these fingerprints automatically, which makes the strategy nearly free to adopt.
The one document that cannot be versioned is the HTML entry point, whose URL is public and stable. It therefore stays on no-cache, revalidated on every visit: it weighs little and references the fingerprinted files. The effect of this policy reads directly in the performance metrics you track in your analytics.
Checking these settings in production takes seconds: a curl -I request on a static file should return a long, immutable Cache-Control, the same request on the HTML a no-cache. On the CDN side, the ratio of responses served from cache to responses forwarded to the origin, often exposed through a diagnostic header such as X-Cache, measures the policy’s real effectiveness far better than the number of stored files. A hit rate that stalls almost always betrays a misplaced header rather than an undersized cache.
Three pitfalls recur in production. Placing a long max-age on an unversioned HTML file freezes the site for visitors until it expires. Forgetting Vary: Accept-Encoding behind a CDN produces unreadable responses. Finally, letting a shared cache store a response mistakenly marked private exposes one visitor’s data to another: reviewing these three points before launch prevents most incidents.
The takeaway
The strategy comes down to three decisions. Choose the freshness lifetime with max-age and s-maxage according to the cache being targeted. Enable revalidation through ETag to turn reloads into bodyless 304 responses. Version static files to combine a one-year cache with instant invalidation. HTML stays revalidated, fingerprinted files are frozen, and Vary protects the variants. This combination covers the vast majority of sites without exotic configuration.
On the sites I have taken over, the most common performance failure was not the absence of caching but a mis-targeted cache: HTML frozen for a day, static files revalidated on every page. I now apply a single rule from the moment a site goes live, before optimising anything else: HTML on no-cache, fingerprinted statics on immutable. It is the setting that pays the most for the least effort, and I have never had cause to regret it. — Simon Janvier
Further reading: the MDN reference on HTTP caching and the RFC 9111 specification.
