HTTP caching headers, explained — Cache-Control, ETag, and 304
You've benefited from HTTP caching a hundred times today — the logo that didn't re-download, the stylesheet that loaded "from disk cache," the page that just felt fast the second time. And if you've ever shipped a change and wondered why nobody can see it, you've fought with it too.
The browser and the server negotiate all of this with a handful of headers,
and the whole protocol is surprisingly small. Most developers have never read
it — they just copy a Cache-Control line from a tutorial and hope.
Here's the actual protocol, in about eight minutes.
- The two kinds of caching — freshness (skip the server) vs. validation (ask, but don't re-download)
Cache-Control's directives, including the two everyone confusesETag,Last-Modified, and the304dance- Copy-paste recipes for static assets, HTML, and APIs
- The mistakes behind every "why is my site stale" ticket
The two kinds of caching
Everything in HTTP caching is a variation on two ideas:
- Freshness caching — "this copy is good until Tuesday." The browser stores the response and uses it without contacting the server at all until it goes stale. Zero network. This is what
max-agecontrols. - Validation caching — "keep this copy, but check with me before using it." The browser still makes a request, but it's a tiny "is this still current?" question. If nothing changed, the server answers with an empty
304 Not Modifiedand the browser reuses what it has. You save the body, not the round trip.
That's the whole mental model. Freshness avoids the server; validation avoids re-downloading. Every header you're about to see is just a knob on one of those two.
Cache-Control — the one that matters
Cache-Control is the modern, authoritative caching header.
It's a comma-separated list of directives:
# fresh for 1 year, any cache, never revalidate (hashed assets)
Cache-Control: public, max-age=31536000, immutable
# store it, but check with the server every single time
Cache-Control: no-cache
# don't store it at all — the real "don't cache"
Cache-Control: no-store
# browser caches 5 min; CDN caches 1 hour
Cache-Control: public, max-age=300, s-maxage=3600
The directives you'll actually use:
max-age=N— fresh for N seconds. Until then, the browser doesn't even ask.public/private— who's allowed to store it.publicmeans CDNs and proxies too;privatemeans only the user's own browser (use this for anything user-specific).no-cache— you may store it, but you must validate before every use.no-store— don't store it, period.immutable— "this will never change; don't even validate on reload." Pairs with a longmax-ageand content-hashed filenames.s-maxage=N— likemax-age, but only shared caches (CDNs) obey it. Lets your CDN cache longer than browsers do.must-revalidate— once stale, you must revalidate; never serve the stale copy.
no-cache does not mean "don't cache." It
means "cache, but revalidate every time." If you actually want nothing
stored — for a bank balance, a one-time token, anything sensitive — you
want no-store. People reach for no-cache when
they mean no-store constantly. Say the two out loud; they
mean opposite things in practice.
There's also Expires, the old HTTP/1.0 way — an absolute date
instead of a relative max-age. If both are present,
Cache-Control wins. Expires depends on the
client's clock being right, which is exactly as reliable as it sounds.
Treat it as legacy; you'll see it in the wild, but you don't need to write
it.
ETag and Last-Modified — the validators
For validation caching to work, the browser needs a way to ask "is my copy still current?" The server hands it one of two tokens on the original response:
Last-Modified— a timestamp of when the resource last changed. Coarse (one-second resolution) but simple.ETag— an opaque identifier for the exact content, usually a hash. Precise, and it works for anything, not just files.
ETag is the stronger of the two — two different files can share
a modification timestamp, but never a content hash. Most servers send both;
browsers prefer ETag when they have it.
The 304 dance
Here's validation in action — the single most common exchange on the web that nobody ever looks at:
# 1 · first visit — full response, plus a validator
GET /app.js HTTP/1.1
HTTP/1.1 200 OK
ETag: "a1b2c3d4"
Cache-Control: no-cache # store it, but always ask
Content-Length: 48213
…48 KB of JavaScript…
# 2 · next visit — browser asks "still the same?"
GET /app.js HTTP/1.1
If-None-Match: "a1b2c3d4"
# 3 · server: "yep, unchanged" — no body at all
HTTP/1.1 304 Not Modified
ETag: "a1b2c3d4"
# browser reuses its stored copy. ~48 KB saved.
The browser sends its validator back in a conditional header —
If-None-Match for an ETag,
If-Modified-Since for a Last-Modified. The server
compares; if nothing changed it returns
304 Not Modified
with an empty body, and the browser serves itself from its own cache.
A 304 saves the body — the 48 KB, the image, the
font. It does not save the round trip; a request still goes out. That's
why max-age (freshness) is the bigger win for truly static
things: no request at all.
Recipes that cover 95% of sites
You rarely need to reason from first principles — most sites fall into three buckets:
# 1 · static assets with content-hashed names (app.a1b2c3.js, logo.9f8e7d.png)
Cache-Control: public, max-age=31536000, immutable
# 2 · your HTML shell — must always point at the latest assets
Cache-Control: no-cache
# 3 · API responses / anything user-specific
Cache-Control: private, no-store
# …and if the response differs per Origin (hello, CORS)
Vary: Origin
The trick that makes recipe #1 work is the content-hashed
filename. When the file changes, its hash — and therefore its URL —
changes, so a year-long cache is safe: the old URL is simply never requested
again. The HTML (no-cache) is the always-fresh index that
points at whichever hashed assets are current.
That pairing — immutable assets + no-cache HTML — is why modern builds feel instant and why your deploys show up immediately. If you've ever cached assets for a year without hashed filenames, you already know the alternative: users stuck on a stale bundle for twelve months.
The mistakes behind every "stale site" ticket
- Using
no-cachewhen you meanno-store. Covered above — it's the classic. Sensitive responses needno-store. - Marking user-specific responses
public. A shared cache or CDN will happily serve user A's data to user B. Anything personalized getsprivate— orno-store. - Long
max-agewithout hashed filenames. You've now guaranteed a stale-asset support ticket on your next deploy. Hash the names, or keep the TTL short. - Forgetting
Vary: Origin. If your server sends different CORS headers per origin, a cache that ignores theOriginheader will serve the wrong one — and produce exactly the baffling CORS error from that other post. - Blaming the browser for a server misconfiguration. Before you tell anyone to "hard refresh," check what headers your server actually sends. The Network tab shows them in one click.
Wrap-up
HTTP caching is two ideas — freshness and validation — expressed through
Cache-Control, ETag, and the
304 handshake. no-cache means "always ask,"
no-store means "never keep," and the recipe of immutable hashed
assets plus no-cache HTML solves most of the web. When a page
misbehaves, the headers in the Network tab tell you exactly who to blame.
If the code in the response is what's confusing you, the
HTTP status lookup has all 71
of them — including 304 — with who's responsible for each. And
if your caching headers are fine but the request itself is failing, that's
usually an auth or gateway
story, not a cache one.