IAnswerable

HTTP Caching Headers, Explained Once

20 Jan 2026

Every 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
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.