HTTP Caching Explained: Cache-Control, ETag and 304 Not Modified
Caching is the cheapest performance win on the web, and the most common source of "why am I still seeing the old version?" It runs on two separate mechanisms that are easy to blur together. Once you can tell them apart, every caching header makes sense.
Mechanism 1: freshness — no request at all
Cache-Control: max-age=3600 tells a browser or CDN that this response stays fresh for an hour. During that hour the cache answers by itself; nothing goes over the network. In DevTools you'll see these as (memory cache) or (disk cache).
Freshness is what makes repeat visits fast. It's also why a bad deploy can stay stuck in browsers: until the response goes stale, the browser doesn't even ask.
Mechanism 2: validation — ask, and maybe get a 304
When a cached response is stale (or marked no-cache), the cache asks the server whether its copy is still good, by sending what it knows:
GET /app.css HTTP/1.1
If-None-Match: "5d8c72a-1f3"
If nothing changed, the server answers 304 Not Modified with no body, and the cache reuses its copy. A few hundred bytes instead of the whole file.
The validators come from the original response:
ETag: "5d8c72a-1f3"— an identifier for this exact version, sent back asIf-None-Match.Last-Modified: Tue, 14 Oct 2026 09:30:00 GMT— sent back asIf-Modified-Since.
The Cache-Control directives that matter
| Directive | Meaning |
|---|---|
max-age=N |
Fresh for N seconds in any cache |
s-maxage=N |
Fresh for N seconds in shared caches (CDNs); overrides max-age there |
public |
Any cache may store it, even for authenticated requests |
private |
Only the user's browser may store it — not CDNs |
no-cache |
May be stored, but must be revalidated before every use |
no-store |
Must not be stored anywhere |
immutable |
Will never change; don't revalidate even on reload |
stale-while-revalidate=N |
Serve a stale copy for up to N seconds while refreshing in the background |
The two most confused are no-cache and no-store. no-cache does not mean "don't cache" — it means "always check first", which with ETags is cheap. no-store is the one that keeps a response out of caches entirely.
A setup that works for most sites
Static assets with a hash in the file name (app.3f2a1c.js): cache forever. A new deploy produces a new file name, so staleness is impossible.
Cache-Control: public, max-age=31536000, immutable
HTML pages: always revalidate, so a deploy shows up on the next load, but let unchanged pages come back as cheap 304s.
Cache-Control: no-cache
ETag: "abc123"
Public API responses behind a CDN: short shared freshness, plus background refresh.
Cache-Control: public, s-maxage=60, stale-while-revalidate=300
Personal data — account pages, API responses with user data:
Cache-Control: private, no-store
When there's no Cache-Control at all
Caches don't just give up. For responses like 200 OK that are cacheable by default, browsers apply heuristic freshness, commonly 10% of the time since Last-Modified. A file last changed 30 days ago may be treated as fresh for three days. That's the hidden cause behind many "users see old code" reports. Always send an explicit Cache-Control.
The Vary header
Vary tells caches which request headers change the response, so they store separate copies:
Vary: Accept-Encoding— separate gzip and Brotli versions (servers add this automatically).Vary: Origin— needed when your CORS headers depend on the caller.Vary: Cookie— effectively disables shared caching, since every user's cookies differ.
Debugging caching
- DevTools → Network: the Size column shows (disk cache) or (memory cache) for fresh hits, and the Status column shows 304 for revalidations. Tick Disable cache to bypass everything.
- curl shows the real headers without any browser cache in the way:
curl -sI https://example.com/app.css. - CDN headers report hits and misses:
cf-cache-statuson Cloudflare,x-vercel-cacheon Vercel (HIT, MISS, STALE, PRERENDER),x-cacheon CloudFront.
To compare headers across many URLs at once, paste them into the HTTP Status Checker. Every header in this guide has its own reference page: Cache-Control, ETag, Last-Modified, If-None-Match and Vary.