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.

In this post
  • 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 confuses
  • ETag, Last-Modified, and the 304 dance
  • 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-age controls.
  • 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 Modified and 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:

cache-control.txt
# 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. public means CDNs and proxies too; private means 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 long max-age and content-hashed filenames.
  • s-maxage=N — like max-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.
The confusion that causes real bugs

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:

the-304-dance.txt
# 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.

What 304 saves (and what it doesn't)

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:

the-recipes.txt
# 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-cache when you mean no-store. Covered above — it's the classic. Sensitive responses need no-store.
  • Marking user-specific responses public. A shared cache or CDN will happily serve user A's data to user B. Anything personalized gets private — or no-store.
  • Long max-age without 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 the Origin header 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.