HTTP Caching Headers, Explained Once
20 Jan 2026Every caching bug you’ve hit — stale content that won’t update, or a CDN re-fetching the same file on every request — traces back to one of three headers set wrong. Here’s what each one actually does.
Cache-Control: the one that matters most
Cache-Control tells browsers and CDNs how long they’re allowed to reuse a response without asking your server again.
Cache-Control: public, max-age=3600
public— any cache (browser, CDN, proxy) may store this.private— only the end user’s browser may cache it; CDNs and shared caches must not (use this for anything user-specific).max-age=3600— reusable for 3600 seconds without revalidation.no-cache— misleadingly named: it can be cached, but must be revalidated with the server before reuse every time. Not the same as “don’t cache.”no-store— actually don’t cache this, anywhere. Use for anything sensitive.
Cache-Control: private, no-store → auth responses, account pages
Cache-Control: public, max-age=31536000, immutable → hashed static assets (app.a3f9c2.js)
Cache-Control: public, max-age=0, must-revalidate → HTML pages that change often
ETag: cache validation without re-downloading
An ETag is a fingerprint of the response body. When a cached response expires, instead of re-downloading the whole thing, the client sends the ETag back and the server can say “still the same” with a cheap 304 Not Modified — no body transferred.
ETag: "a1b2c3d4"
# Client's next request:
If-None-Match: "a1b2c3d4"
# Server, if unchanged:
HTTP/1.1 304 Not Modified
This matters most for resources that change unpredictably (API responses, dynamic pages) where you can’t set a long max-age but still want to avoid re-sending unchanged bytes.
Last-Modified: the older, coarser version of ETag
Same idea, timestamp-based instead of hash-based:
Last-Modified: Tue, 15 Jan 2026 09:00:00 GMT
If-Modified-Since: Tue, 15 Jan 2026 09:00:00 GMT
Less precise than ETag (second-level granularity, so two changes within the same second look identical) but cheaper to compute — no hashing required, just a file’s mtime. Fine for static files; prefer ETag for anything where correctness matters more than a marginal CPU saving.
The decision table
| Resource type | Cache-Control | Why |
|---|---|---|
Hashed static asset (main.a3f9c2.js) |
public, max-age=31536000, immutable |
Filename changes when content changes — safe to cache forever |
| HTML page | public, max-age=0, must-revalidate |
Content changes; always check freshness, but allow instant 304s |
| API response (dynamic, per-request) | private, no-store or short max-age |
Usually user-specific or changes too often to cache meaningfully |
| User account/auth pages | private, no-store |
Never let a shared cache or CDN serve one user’s page to another |
| Images/fonts (unversioned filename) | public, max-age=86400 |
Cache for a day; balances freshness against re-fetch cost |
The immutable + hashed filename pattern
The strongest caching setup is the one that avoids revalidation entirely: give every build’s static assets a content hash in the filename (main.a3f9c2.js instead of main.js), then cache them forever with immutable. When you deploy new code, the filename changes, so there’s no staleness to worry about — the old cached file simply stops being referenced. Most modern bundlers (webpack, Vite, Next.js) do this automatically for build output; you only need to set the header.
The mistake that causes “my changes aren’t showing up”
Setting max-age=31536000 on an HTML file, or on any asset without a content hash in its name, means browsers and CDNs will serve the old version for up to a year regardless of what you deploy. If you ever see “I pushed the fix but it’s still broken for users,” check this first — it’s almost always a long max-age on a resource whose filename doesn’t change when its content does.
The takeaway
Hash your static asset filenames and cache those forever with immutable. Cache HTML with max-age=0, must-revalidate so it always checks but can 304 cheaply. Never cache anything user-specific with public. Get those three rules right and most caching bugs disappear.