HTTP caching, layer by layer
The journey of a web request ends step 7 with one line: headers like Cache-Control decide whether the next visit can skip several of the steps above entirely. Its "second visit" table even has a row that just says may return 304 Not Modified. This note opens that row up — what "skip" actually means, who gets to decide, and what goes wrong when they decide badly.
What an HTTP cache actually does
A cache stores a response and files it under a key — the URL, plus any request headers the response lists in Vary. The next time a request with the same key comes along, there are only three things that can happen:
- Fresh hit — the stored copy is still good. No request leaves the cache at all.
- Revalidation — the stored copy is old, so the cache asks the server "has this changed?" The server answers
304 Not Modifiedwith no body, and the stored copy gets reused. - Miss — nothing usable is stored. The request goes all the way through, and a full
200comes back.
Freshness: "is my copy still fresh?"
The server answers the first question up front, when it sends the response:
Cache-Control: max-age=300 ← set by your server
Age: 120 ← added by the CDN that handed it over
max-age is how many seconds the response stays fresh after the server generated it. Age is how many of those seconds have already been used up — usually by a CDN that held it before you. This copy has 180 seconds of freshness left.
If this feels familiar, it should. Configuring DNS records called TTL "a cache expiry, with the usual trade-off" — max-age is the same idea, one layer up:
| DNS | HTTP | |
|---|---|---|
| How long to trust a copy | TTL | max-age |
| Time already spent in a cache | the TTL counting down in dig output | the Age header |
| Low value | changes land fast, more lookups | changes land fast, more requests |
| High value | fewer lookups, stale answers linger | fewer requests, stale pages linger |
Validation: "is it still correct?"
Once a copy goes stale, the cache doesn't throw it away. It asks the server a conditional question, using an identifier the server handed out with the original response:
| Server sent | Cache asks with | Meaning |
|---|---|---|
ETag: "y1r81cnrtw1bww" | If-None-Match: "y1r81cnrtw1bww" | "Is this still the version you'd send?" |
Last-Modified: <date> | If-Modified-Since: <date> | "Has it changed since then?" |
If nothing changed, the answer is a 304 with no body. It still costs a round trip, but none of the bandwidth — for a 60 KB HTML page, that's the difference between a few hundred bytes and the whole document. ETag is the more precise of the two, since a timestamp only has one-second resolution and a file can be rewritten without its content changing.
The layers: browser → CDN → origin
So far "the cache" has been singular. In practice a request passes through several, and stops at the first one that can answer it:
- Browser cache — in memory for the current tab session, on disk across sessions. Private to one user.
- Shared cache — a CDN or proxy sitting in front of your server. One copy serves everyone who hits that edge location.
- Origin — your server, which may have its own application-level caching behind it (outside the scope of this post).
Here is one page visited four times over fifteen minutes. Watch where each request stops:
Each layer asks the same two questions — but it doesn't have to read the same answers. Cache-Control has directives aimed at specific layers:
| Question | Browser cache reads | Shared cache (CDN) reads |
|---|---|---|
| Am I allowed to store this? | anything not no-store | only if not private (and public if the request carried credentials) |
| Is my copy still fresh? | max-age | s-maxage, falling back to max-age |
| Is it still correct? | ETag / Last-Modified → asks the next layer down | ETag / Last-Modified → asks the origin |
The last row is the one people miss: the browser's conditional request goes to the next layer, not necessarily your server. Here's lamlq.dev answering a revalidation:
$ curl -I https://lamlq.dev -H 'If-None-Match: "y1r81cnrtw1bww"'
HTTP/2 304
cache-control: public, max-age=0, must-revalidate
x-vercel-cache: HIT
The browser is told to revalidate every time (max-age=0), and it does — but the 304 comes from Vercel's edge (HIT), not from the origin. The browser checks in on every visit; the origin barely hears about it.
Serving stale on purpose
Sometimes an old answer right now is better than a fresh answer in 400ms. Two directives say so:
stale-while-revalidate=N— for up to N seconds after going stale, serve the stale copy immediately and fetch a fresh one in the background for the next request.stale-if-error=N— if the origin is down or erroring, keep serving the stale copy for up to N seconds instead of passing the error on.
Visit 4 in the animation above is exactly this. You can watch it happen for real, too — two requests to lamlq.dev, a minute apart:
$ curl -sI https://lamlq.dev | grep -iE '^(age|x-vercel-cache)'
age: 4475
x-vercel-cache: STALE
$ curl -sI https://lamlq.dev | grep -iE '^(age|x-vercel-cache)'
age: 46
x-vercel-cache: HIT
The first request got a copy that was over an hour old, instantly, and triggered a regeneration. The second got the regenerated copy, 46 seconds old. Nobody waited for a render.
If that sounds like ISR from web rendering strategies, that's because it is. ISR is, mechanically, a shared cache with a freshness window plus stale-while-revalidate — the framework just picks the numbers for you.
Invalidation: the actual hard part
Putting something in a cache is one header. Getting it out again is where the real design work lives, because you can't reach into someone's browser and delete a file. Once a response is stored with max-age=86400, that user has it for a day, whatever you deploy in the meantime.
The way around it is to never need to invalidate at all: if the content changes, change the URL. Build tools put a content hash in every asset's filename, and those files get cached for a year:
$ curl -I https://lamlq.dev/_next/static/chunks/2zpfr2m3fj0n7.css
cache-control: public,max-age=31536000,immutable
immutable tells the browser not to even revalidate on reload — this URL will never mean anything else. When the CSS changes, the next build produces a different filename, and the HTML points at the new one.
That shifts all the weight onto the HTML. It's the one file whose URL can't change, so it gets the opposite treatment — max-age=0, must-revalidate — and it's always the first thing re-checked. Long-lived assets, short-lived entry point: that pairing is most of HTTP caching strategy.
The CDN layer is different: it's your cache, so you can purge it. Vercel, Cloudflare and friends all expose a purge API, and on Vercel each deployment gets its own cache keys, so a new deploy starts with a clean edge cache. The browser layer is the one with no delete button.
A header recipe per resource
| Resource | Cache-Control | Why |
|---|---|---|
| HTML page | public, max-age=0, must-revalidate | The entry point — its URL can't change, so always check. Let the CDN answer. |
| Hashed JS / CSS | public, max-age=31536000, immutable | The URL changes when the content does. |
Unhashed public file (/og.png) | public, max-age=3600 | Can change under the same URL; keep the window short. |
| Public API response | public, s-maxage=60, stale-while-revalidate=300 | Shared across users; the CDN absorbs the load. |
| Per-user API response | private, no-cache | Browser may keep it, CDN must not. |
| Sensitive data (tokens, payment details) | no-store | Don't leave it on disk anywhere. |
Failure modes you'll actually meet
"I deployed, but users still see the old version"
Usually one of two things: the HTML itself was given a long max-age, so browsers never go back to learn about the new asset filenames — or an unhashed file changed under the same URL. The fix for next time is the pairing above. The fix for right now is a CDN purge, and waiting out whatever browsers already hold.
Someone else's data, served to you
The most serious one. A response personalized for one user — a profile page, a cart, an /api/me — goes out without private, and the CDN stores it and serves it to the next person who asks for that URL. Steam had exactly this happen in 2015: a caching configuration change during a traffic spike served users other people's account pages.
The defense is to make "private" the default for anything behind authentication, and to be deliberate about every public and s-maxage you write.
"It works when I disable the cache"
Each way of refreshing skips a different slice of the layers:
| Action | Browser cache | CDN |
|---|---|---|
| Normal reload | revalidates the page; subresources used as normal | used |
Hard reload (Cmd+Shift+R / Ctrl+Shift+R) | bypassed for everything | still used |
| DevTools "Disable cache" | bypassed while DevTools is open | still used |
curl | not involved | still used |
Nothing on the client side reaches past the CDN. If a hard reload doesn't fix it, the stale copy is on the edge, and x-vercel-cache (or cf-cache-status on Cloudflare) in the response headers will tell you so.
Try it yourself
Run these against lamlq.dev:
curl -I https://lamlq.dev # HTML: max-age=0, an ETag, and the CDN's verdict
curl -I https://lamlq.dev -H 'If-None-Match: "<etag from above>"' # a 304, answered by the edge
curl -sI https://lamlq.dev | grep -i '^age' # run it twice — the Age header counts up
Then open DevTools → Network, reload, and look at the Size column. (memory cache) and (disk cache) are fresh hits that never left the browser. A tiny size next to a 304 status is a revalidation. Everything else went to the network.