Deep Dive: HTTP Caching
Related: 01 Internet, 09 Build Tools, 16 Performance, 17 Browser APIs & PWA
Opening questions
Why do users still see the old version after a deploy? Why does no-cache still cache? Why can a JS file with a hash in its name be cached for a year, but index.html can't? What's actually different between "(disk cache)" and "304" in the Network panel?
Mental model
Every cached response faces two questions:
- Is it still fresh? If so, the browser uses it without touching the network at all.
- If it's stale, is it still the same? The browser asks the server with a conditional request. If nothing changed, the server replies
304 Not Modifiedwith no body, and the cached copy is reused.
request → in cache?
├─ no → fetch from network → store if allowed
└─ yes → fresh? (age < freshness lifetime)
├─ yes → use cache (0 round trips)
└─ no → revalidate (If-None-Match / If-Modified-Since)
├─ 304 → use cache, refresh metadata (1 round trip, no body)
└─ 200 → use the new response, replace the cacheFreshness saves round trips. Validation saves bandwidth. A good caching strategy picks the right one for each kind of resource.
Detailed mechanism
Freshness
Cache-Control: max-age=N: fresh for N seconds from when the response was generated. TheAgefrom proxies/CDNs counts toward it.s-maxage=N: the same, but only for shared caches (CDNs, proxies), where it overridesmax-age.Expires: <date>: the old way. Ignored whenmax-ageis present.- Heuristic freshness: with no
max-ageorExpiresbut aLast-Modified, caches may guess, typically 10% of the file's age. A file last changed 30 days ago may be treated as fresh for about 3 days without ever asking the server. That's the classic "why is it still the old version?".
Validation
| Validator from the server | Sent back by the browser |
|---|---|
ETag: "abc123" | If-None-Match: "abc123" |
Last-Modified: <date> | If-Modified-Since: <date> |
ETags are more precise (no clock or 1-second resolution issues). When both are present, the server uses If-None-Match.
Commonly misunderstood directives
| Directive | What it actually means |
|---|---|
no-cache | May be stored, but must be revalidated before every use |
no-store | Don't store it at all (sensitive data) |
private | Browser cache only, not CDNs/proxies (per-user responses) |
public | Shared caches may store it, even responses they normally wouldn't (e.g. with Authorization) |
must-revalidate | Once stale, never use it without revalidating, even if the server is unreachable |
immutable | While fresh, don't revalidate even when the user reloads |
stale-while-revalidate=N | Once stale, may be used for N seconds while revalidating in the background |
stale-if-error=N | May serve the stale copy if the server errors |
Vary
Vary: Accept-Encoding means the response is cached per value of that header: gzip and brotli versions are stored separately. Vary: Cookie or Vary: User-Agent on a CDN is almost the same as turning caching off, since nearly every user sends a different value.
Cache layers
Lookup order, roughly: memory cache (within the tab, for recently used resources) → Service Worker (if any, and it decides whatever it wants) → HTTP cache (disk) → CDN / proxy → origin. The bfcache (back/forward cache) is separate: it keeps the whole live page when the user presses back, not HTTP responses.
Reload behavior
- Normal navigation: freshness rules apply as usual.
- Reload: the main document is always revalidated. Chrome only revalidates the main document, while Firefox also revalidates subresources unless they're marked
immutable. - Hard reload (Ctrl/Cmd+Shift+R): bypasses the cache.
Experiments
- Look at the real headers.bashThen fetch a file under
curl -sI https://chalidade.github.io/fet/ | grep -iE "cache-control|etag|last-modified|age|expires"/fet/assets/and compare. Why are the values the same even though one has a hash in its name? (GitHub Pages can't be configured per file.) - A 304 by hand. Take the ETag from step 1, then:bash
curl -sI -H 'If-None-Match: "<etag>"' https://chalidade.github.io/fet/ - Your own server. Write a tiny Node server that returns a different
Cache-Controlper path (max-age=10,no-cache,no-store,max-age=31536000, immutable). Open it in DevTools with Disable cache off, and note the Size column:(memory cache),(disk cache),304, or the full size. Try a normal navigation, a reload, and a hard reload. - Heuristic freshness. Remove
Cache-Control, send aLast-Modifiedfrom 30 days ago, change the file, then do a normal reload. Does the browser ask the server?
Practical implications
The pattern almost every modern app uses:
| Resource | Header | Why |
|---|---|---|
Hashed assets (app.3f9a1c.js, fonts, bundler images) | public, max-age=31536000, immutable | New content = new name, so caching forever is safe |
| HTML / entry point | no-cache (plus an ETag) | Always revalidate so a new deploy shows up immediately, still cheap via 304 |
| Public API data | Short max-age + stale-while-revalidate | Fast and fresh enough |
| Per-user responses | private, no-cache | Must never be stored on a CDN and leak to other users |
| Sensitive data (tokens, banking) | no-store | Never written to disk |
- Never give a long
max-ageto a URL whose content can change. There's no way to "recall" a response already cached in users' browsers. - If users see an old version after a deploy, check the HTML headers first: there's almost always a
max-ageor heuristic freshness at play. - A Service Worker adds a cache layer you control, and also a new way to serve stale versions. Make sure you have a clear update strategy.