HTTP Caching
How browsers and CDNs decide to reuse a response: freshness with Cache-Control, validation with ETag, cache keys with Vary, and the recipes that actually work.
An HTTP cache sits between a client and an origin and answers a request from a stored response instead of forwarding it. Two decisions drive everything: is the stored copy fresh (decided locally from Cache-Control), and if not, is it still valid (decided by asking the origin with ETag or Last-Modified, which costs a round trip but usually no bytes). Get those two right and most pages stop hitting your server at all.
Why it matters
Caching is the cheapest performance win that exists: a cache hit is a request your server never sees and a byte the user never downloads. It is also the source of the two scariest bugs in web development, "users see the old version after a deploy" and "user A saw user B's data". Both are misconfigured headers, and both are preventable once you can read the headers as the cache reads them.
Two kinds of cache, one set of headers
Every response passes through up to two caches on its way back to a user: a private cache (the browser, one user) and zero or more shared caches (a CDN edge, a reverse proxy, many users). They read the same headers, but a shared cache must be more careful, because storing a personalised response there leaks it to everyone.
The origin sets the policy; the caches enforce it. Nothing in the browser or the CDN can make a response cacheable that the origin marked no-store, and nothing forces a revalidation of a response the origin marked fresh for a year. That is why caching bugs are almost always fixed on the server.
Freshness: Cache-Control
A response is fresh while its age is below its freshness lifetime. While fresh, a cache serves it without contacting the origin at all. Lifetime comes from Cache-Control: max-age (seconds, from when the origin generated it), and shared caches can be given a different number with s-maxage.
| Directive | Meaning | Typical use |
|---|---|---|
max-age=N | Fresh for N seconds in any cache. | Everything cacheable |
s-maxage=N | Overrides max-age for shared caches only. | Long CDN TTL, short browser TTL |
public / private | private forbids shared caches from storing it. | private for any per-user response |
no-cache | May store, but must revalidate before every reuse. | HTML documents, API responses |
no-store | Never store anywhere. The only real “don’t cache”. | Bank balances, tokens |
must-revalidate | Once stale, never serve without a successful revalidation. | Correctness over availability |
immutable | Content will never change; skip revalidation even on reload. | Hashed asset files |
stale-while-revalidate=N | Serve stale for up to N s while refreshing in the background. | Feeds, listings, anything “eventually fresh” |
If the origin sends no explicit freshness at all, caches are allowed to guess. The common heuristic is 10% of the time since Last-Modified. A file untouched for a year may be silently cached for over a month, which is how "we never set caching headers" still produces stale-content bugs. Always be explicit.
Validation: ETag, Last-Modified and the 304
When a stored response is stale (or marked no-cache), the cache does not throw it away. It sends a conditional requestcarrying the stored validator, and the origin answers either "unchanged" with an empty 304 Not Modified, or with a full 200 and a new body.
- 1
Origin includes a validator on the first response:
ETag: "abc123"(an opaque hash or version) and/orLast-Modified(a second-precision date). - 2
On reuse after expiry, the cache sends
If-None-Match: "abc123"(orIf-Modified-Since). - 3
The origin compares against the current version. Match →
304with freshCache-Controlheaders and no body. Mismatch → a normal200.
import {createHash} from 'node:crypto';
app.get('/api/catalog', async (req, res) => {
const body = JSON.stringify(await loadCatalog());
const etag = '"' + createHash('sha1').update(body).digest('hex') + '"';
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'private, no-cache');
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // no body, ~200 bytes on the wire
}
res.type('application/json').send(body);
});Prefer ETag over Last-Modified: it survives sub-second edits, works for generated content with no file date, and can be a cheap version number instead of a hash. Note that the origin still does the work of producing the body in this example; the saving is bandwidth and client parse time, not server CPU. To save CPU too, validate against a version stored next to the data.
Cache keys and Vary
A cache stores responses under a key, by default the method plus the full URL. If the same URL can legitimately return different bodies depending on a request header (compressed vs not, JSON vs HTML, one origin vs another), the origin must say so with Vary, and the cache adds those header values to the key.
HTTP/1.1 200 OK
Content-Encoding: gzip
Cache-Control: public, max-age=600HTTP/1.1 200 OK
Content-Encoding: gzip
Cache-Control: public, max-age=600
Vary: Accept-EncodingThe flip side: every header you list multiplies the number of copies. Vary: User-Agent effectively disables shared caching, because almost every browser build has a unique string. Vary: Cookie does the same for logged-in traffic. List only what actually changes the body.
Recipes that work
| Resource | Headers | Why |
|---|---|---|
| Hashed static assets (app.3f9a1c.js) | public, max-age=31536000, immutable | The URL changes when the content does, so the copy can never go stale. |
| HTML documents | no-cache | Always revalidate so a deploy shows up immediately; the 304 keeps it cheap. Add an ETag. |
| Public API / listing pages | public, s-maxage=60, stale-while-revalidate=300 | CDN serves instantly and refreshes in the background; browser still revalidates. |
| Per-user API responses | private, no-cache | Browser may keep a copy and revalidate; CDN must not store it. |
| Secrets, balances, tokens | no-store | Never on disk, never in a shared cache, never in the back-forward cache either. |
The deploy problem, solved by the first two rows
"Users still see the old JS after a deploy" happens when the HTML is cached for a while and it references un-hashed asset URLs, or when hashed assets are cached but the HTML that points at the new hashes is stale. With HTML on no-cache and assets on immutable hashed URLs, every visit picks up the new HTML with one cheap 304 check, and the new HTML points at new URLs that cannot collide with anything cached.
Pitfalls
- Set-Cookie on a publicly cacheable response
A shared cache that stores a response with
Set-Cookiewill hand user A's session cookie to user B. Most CDNs refuse to cache such responses, but reverse proxies you configure yourself often do. Never combinepublicwith a cookie-setting response. - Caching by URL when the response depends on Authorization
Per RFC 9111 a shared cache must not store a response to a request with an
Authorizationheader unless the origin explicitly allows it withpublic,s-maxageormust-revalidate. Addingpublicto "make the CDN work" is how personal data ends up shared. - Trusting Expires or no headers at all
Expiresis an absolute date computed from the server clock; a skewed clock makes everything expired or eternal. And no headers means heuristic freshness. Always sendCache-Controlwith an explicitmax-ageorno-cache. - Busting the cache with a query string instead of a hash
app.js?v=3works in browsers but some proxies ignore the query string in the key, and it keeps the old and new versions under URLs that differ only cosmetically. Put the content hash in the file name and mark itimmutable. - Vary: * or Vary on high-cardinality headers
Vary: *tells caches the response can never be reused safely, andVary: User-Agentfragments the cache into thousands of near-duplicate entries. Vary on the one or two headers that actually change the body, usuallyAccept-Encodingand, for CORS,Origin.
Interview questions
Q1What is the difference between Cache-Control: no-cache and no-store?
no-cache permits storing the response but requires revalidation with the origin before every reuse, typically a cheap conditional request answered with 304. no-store forbids storing it anywhere at all. Use no-cache for HTML and APIs where you want freshness with low cost, and no-store only for genuinely sensitive data.
Q2Walk me through what happens when a cached response has expired and the user requests it again.
The cache sees the age exceeds max-age, so it sends a conditional request to the origin with If-None-Match carrying the stored ETag (or If-Modified-Since). If the origin still has the same version it returns 304 with no body and new freshness headers, and the cache resets the age and serves the stored copy. Otherwise the origin returns 200 with the new body, which replaces the old entry.
Q3We deployed a new version but users still see the old JavaScript. What went wrong and how do you fix it permanently?
Either the HTML was cached with a positive max-age so it still references old assets, or the assets are served under stable URLs with a long max-age so the browser never re-fetches them. The permanent fix is content-hashed asset file names with max-age=31536000, immutable, and HTML served with no-cache plus an ETag, so each visit revalidates the HTML cheaply and picks up new asset URLs.
Q4How does stale-while-revalidate change the user experience, and when is it appropriate?
When the copy is stale but within the SWR window, the cache serves the stale copy immediately and refreshes it in the background, so the user never waits on the origin. It is appropriate for content where being a few seconds or minutes behind is acceptable, such as listings, feeds and product pages, and inappropriate for anything transactional.
Q5What is the Vary header for, and what goes wrong if you omit it or overuse it?
Vary tells caches which request headers change the response, so those values become part of the cache key. Omit it and a cache can serve a gzip body to a client that did not accept gzip, or one origin's CORS response to another. Overuse it, say on User-Agent, and the cache fragments so much it stops hitting.
Q6Why is max-age preferred over Expires?
max-age is relative to when the response was generated and is adjusted by the Age header as it passes through caches, so it does not depend on synchronized clocks. Expires is an absolute timestamp and breaks whenever the server or client clock is wrong. When both are present, max-age wins.
Q7Can a CDN cache a response that depends on a logged-in user? How would you make personalization work with a CDN?
Not safely, because the key is the URL and one user's body would be served to another. Mark such responses private. To keep the CDN useful, split the page: cache the shared shell publicly and fetch the personalized parts client-side or through an uncached endpoint, or use edge logic that keys on the session explicitly.
- Fresh responses are served without contacting the origin; stale ones are revalidated with a conditional request that usually returns a bodyless 304.
- no-cache means "store but revalidate"; no-store is the only directive that forbids storage.
- Private caches belong to one user; shared caches (CDNs, proxies) must never store per-user responses, so mark them private.
- Prefer ETag over Last-Modified and Cache-Control max-age over Expires.
- Hashed, immutable assets plus no-cache HTML solve the "stale after deploy" problem for good.
- Vary on the headers that actually change the body, usually Accept-Encoding and Origin, and nothing else.