HTTP caching
The browser's built-in cache, steered by headers on each response, which lets a repeat visit skip the network entirely or ask only "has this changed?": the cheapest speed-up on the web.
Why caching matters
Picture an online shop. Its home page is one HTML file plus about 60 other files: two JavaScript bundles, a stylesheet, three fonts, a logo, and a grid of product images. Together they weigh about 1.6 MB. A loyal customer opens the shop every morning on a phone with a 4G connection.
Almost nothing changes from one morning to the next. The bundles are the same, the fonts are the same, most of the images are the same. Only the HTML, with today's deals, is new. Yet without caching the phone downloads all 1.6 MB again, every day, and waits for every file.
HTTP caching fixes this. The server adds a few headers to each response that say how long the response stays good and how to check whether it changed. The browser, and every cache between the browser and the server, follows those headers. No JavaScript is needed: it is the cheapest speed-up on the web.
The three costs a cache saves
Every request the browser makes costs something in three places. A cache can remove some or all of each cost, which is why it shows up in every performance and system design discussion.
- Round trips. A request has to travel to the server and the answer has to travel back. One round trip on 4G is often 50 to 100 ms, and much more on a weak signal or across an ocean. A page that needs three rounds of requests (HTML, then the CSS and JS it names, then the fonts and images those name) pays that latency at least three times before the user sees the page.
- Bytes. Every kilobyte costs download time, mobile data, and battery. 1.6 MB on a 10 Mbps link is about 1.3 seconds of transfer on its own, before any parsing or rendering.
- Server load. Every request that reaches your servers costs CPU, bandwidth, and money. 2 million daily visitors who each load 60 files is 120 million requests a day, about 1,400 per second on average and several times that at peak.
Caching attacks the three costs at different depths. If the browser already has a good copy, the request never leaves the device: zero round trips, zero bytes, zero server work. If the copy might be old, the browser asks a short question and gets a short answer: one round trip, a few hundred bytes. If the copy is in a CDN near the user rather than in the browser, the trip is short and your own servers never see it.
no-cache HTML file save both.Three things can happen to every request
Keep these three outcomes in mind for the rest of the lesson. Every header you will meet exists to push a request from the bottom of this list towards the top.
- Fresh hit. The cache has a copy that is still within its allowed lifetime. It answers at once. No network at all.
- Revalidation. The cache has a copy, but its lifetime is over. It asks the server "I have the version tagged
"a1b2", is that still current?". If yes, the server answers304 Not Modifiedwith no body, and the cache reuses its copy. One round trip, almost no bytes. - Miss. The cache has nothing usable. The full response comes from the server: one or more round trips and every byte.
On the shop's repeat visit, a well-configured site turns all 60 other files into fresh hits and only revalidates the HTML. A site that sends no caching headers at all (no Cache-Control and no validators) downloads all 61 files again. Same code, same server, same user: the difference is a handful of response headers.
Why teams still get it wrong
Caching is easy to switch on and easy to get wrong in two opposite directions. Cache too little and the site is slow and expensive. Cache too much and users see an old version after a deploy, or worse, a CDN shows one user's account page to another user.
Both failures come from the same gap: not knowing exactly what each header means and which caches obey it. The rest of this lesson closes that gap, one header at a time, and ends with a small set of rules you can apply to any site.
Check your understanding · Exercise 1
A file is served from the browser's cache as a fresh hit. Which costs does that remove? Select all that apply.
The mental model: copies with a best-before date
Think of the milk in your fridge, with its best-before date. Before that date you drink it without a second thought. After the date you don't throw it away at once: you smell it, and if it's fine you keep using it. Only if it's gone off do you go to the shop for a new carton.
An HTTP cache treats every stored response the same way. The server prints the best-before date on the response (Cache-Control: max-age=600 means "good for 600 seconds"). Before that, the cache serves the copy without asking (a fresh hit); after it, the cache asks the server whether its copy is still good (revalidation). Only if the answer is no, or the cache has no copy, does it fetch the whole thing: a miss.
Two words carry the whole model. A response is fresh while its age is below its freshness lifetime, and stale after that. Stale does not mean wrong; it means "unchecked". Most of the headers in this lesson either set the lifetime, say what to do once stale, or say who may keep a copy at all.
A response passes through a chain of caches
There is not one cache but several, stacked between the page and your server. A request walks down the stack until some layer can answer it. The answer then walks back up, and each layer may keep a copy on the way.
Cache-Control steers the HTTP cache and the CDN; the service worker follows only your code.- Memory cache. A short-lived store inside the tab's renderer, tied to the current page. It is why the same image used twice on one page is only fetched once. It disappears when the page goes away. DevTools shows its hits as
(memory cache). - Service worker. If the site registered one, it sees every request from its pages before the HTTP cache does, and can answer from its own Cache API store with code you write. HTTP caching headers do not control it: your code does. DevTools shows
(ServiceWorker). - HTTP cache (disk cache). The browser's main cache, stored on disk, shared by every tab of the same site and kept across restarts. This is the cache that
Cache-Controlsteers most directly. DevTools shows(disk cache). - Shared caches. CDN edge servers (Cloudflare, Fastly, Akamai, CloudFront), and sometimes a reverse proxy like Varnish or nginx in front of your app. One stored copy serves many users, which is great for speed and dangerous for personal data.
- Origin. Your own server, which makes the response and decides the headers every layer above will follow.
The spec, RFC 9111, splits these into two kinds. A private cache belongs to one user: the browser's HTTP cache. A shared cache stores responses for many users: CDNs and proxies. Several directives exist only to treat the two differently, so keep the split in mind.
The cache key: how a cache finds a stored copy
A cache is a map. To find a stored response it builds a cache key from the request and looks it up. By default the key is the request method plus the full URL, including the query string. In practice only GET (and HEAD) responses are cached, so the URL does almost all the work.
This has a sharp consequence: different URLs are different entries, even if they return the same bytes. /logo.png?utm_source=mail and /logo.png are two misses, not one. CDNs let you normalize the key (drop tracking parameters, sort the rest) for exactly this reason.
The Vary response header adds request headers to the key. Vary: Accept-Encoding says "I sent you a Brotli body because the request said it accepts br; a client that doesn't accept br must not get this copy". The cache then stores the value of Accept-Encoding with the copy and only reuses it for requests with a matching value.
| Vary value | What it means | Effect on hit rate |
|---|---|---|
Accept-Encoding | The body depends on compression (gzip, br, zstd). | Fine: there are only a few common values, and CDNs normalize them. |
Accept-Language | The body depends on the user's language. | Risky: real headers vary wildly (en-GB,en;q=0.9,fr;q=0.8), so copies fragment. Prefer the language in the URL. |
Cookie | The body depends on the cookies. | Very low: almost every user has a unique cookie string, so a shared cache stores one copy per user and hits almost never. |
* | The body depends on things the request headers can't describe. | Zero: no request ever matches, so the response is never reused. |
Browsers add one more part to the key. Chrome, Firefox, and Safari all partition the HTTP cache by the top-level site (Safari since 2013, Chrome since late 2020, Firefox since early 2021). A font from a public CDN cached while you browse a.com is a separate entry from the same URL loaded on b.com. This stops one site from learning what you visited elsewhere by timing cache hits, and it also ended the old idea that a popular CDN copy of a library is "probably already cached".
Check your understanding · Exercise 2
A CDN has /app.css stored. Which of these requests can it answer from that copy, assuming it is fresh and the response had Vary: Accept-Encoding? Select all that apply.
How it works, step by step: freshness, then validation
Let's follow one file, /api/menu.json on a restaurant site, through a morning. Watching the exact headers is the fastest way to understand caching, so every step shows them.
09:00, the first request: a miss
The browser has nothing stored, so it sends a plain GET. The server answers with the body and these headers:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 18240
Cache-Control: max-age=600
ETag: "m-7f3a"
Last-Modified: Mon, 19 Oct 2026 08:40:00 GMT
Date: Mon, 19 Oct 2026 09:00:00 GMT
Mon, 19 Oct 2026 08:40:00 GMT.Three pieces matter. Cache-Control: max-age=600 is the freshness lifetime, 600 seconds, and ETag is a validator, an opaque version tag the server can compare later. Last-Modified is a second, older kind of validator. The browser stores the body and all the headers.
09:05, a fresh hit
The page asks for /api/menu.json again. The stored copy is 300 seconds old and its lifetime is 600, so it is fresh. The browser hands it over without touching the network. In DevTools the Size column says (disk cache) and the time is a millisecond or two.
The browser works out the age itself: the time since it received the response, plus any Age header the response arrived with. Age is added by shared caches and counts the seconds the copy already spent there. If a CDN had held this response for 500 seconds before handing it over, it would carry Age: 500, and the browser would treat it as fresh for only 100 more seconds. A max-age is a lifetime from when the origin made the response, not from when you received it.
09:15, stale: a conditional request
Now the copy is 900 seconds old: stale. The browser does not throw it away. It sends a conditional request, quoting the validators it kept:
GET /api/menu.json HTTP/1.1
If-None-Match: "m-7f3a"
If-Modified-Since: Mon, 19 Oct 2026 08:40:00 GMT
HTTP/1.1 304 Not Modified
Cache-Control: max-age=600
ETag: "m-7f3a"
Date: Mon, 19 Oct 2026 09:15:00 GMT
If-None-Match means "send the full response only if your current ETag is none of these". The menu hasn't changed, so the server answers 304 Not Modified with headers and no body: a few hundred bytes instead of 18 KB. The browser updates the stored headers from the 304, resets the age to zero, and serves its stored body. The copy is fresh again until 09:25.
If the menu had changed, the server would answer 200 OK with the new body and a new ETag, exactly like a miss. So a revalidation never costs more than a miss, and when nothing changed it costs only one round trip and a little header traffic.
304 carries no body, so the 18 KB isn't downloaded again.Validators: ETag and Last-Modified
An ETag (entity tag) is any quoted string the server chooses: a hash of the body, a database version number, a build id. The only rule is that it changes when the content changes. A strong ETag like "m-7f3a" promises the bytes are identical, while a weak ETag like W/"m-7f3a" promises only that the content means the same thing, for example the same JSON with different whitespace or a different compression. For If-None-Match either kind works; byte-range requests need strong ones.
Last-Modified is a date. The browser sends it back as If-Modified-Since, and the server answers 304 if nothing changed since then. It is easy to produce from a file's modification time, but it has one-second resolution and it breaks when files are copied or rebuilt without changing. When a request carries both, the server must use If-None-Match and ignore the date, so send an ETag when you can.
- ETags that differ per server. Some servers build the ETag from the file's inode or timestamp. Behind a load balancer, two servers give the same file two ETags, so revalidations randomly turn into full downloads. Use a content hash.
- Compression changes the ETag. A server or CDN that compresses on the fly may weaken (
W/) or drop a strong ETag, because the bytes changed. That's correct behaviour; just don't be surprised when the header you set isn't the one that arrives. - A 304 that forgets headers. The 304 should repeat
Cache-Control,ETag, andVary. Caches update the stored headers from it, so a 304 withoutCache-Controlcan leave the copy with an outdated policy.
No caching headers at all: heuristic freshness
What does the browser do with a response that has a Last-Modified date but no Cache-Control and no Expires? It does not treat it as uncacheable. The spec allows a cache to guess a lifetime, called heuristic freshness, and browsers use the rule the spec suggests: 10% of the time since the last modification.
So a main.js last modified 10 days ago is treated as fresh for about a day. If you deploy a new main.js under the same URL tomorrow morning, returning users can run the old file for hours with no request at all. This is the classic "it works after a hard refresh" bug: its cause is a missing header, not a wrong one, so always set Cache-Control explicitly. (Expires, an absolute date, is the older way to set a lifetime; if both are present, max-age wins.)
Use the simulator to watch these rules play out. Pick a policy, send requests, move the clock, and change the file on the server. Notice how many bytes and round trips each policy costs over the same morning.
Clock: 0 s. The server has v1 (18.2 KB), last modified 10 days ago. Nothing stored.
Request the file to start. Then wait, request again, and change the file to see each outcome.
Check your understanding · Exercise 3
A browser receives a response with Cache-Control: max-age=3600 and Age: 3000 from a CDN. What happens? Select all that apply.
Cache-Control, one directive at a time
Cache-Control is a comma-separated list of directives, like public, max-age=31536000, immutable. Each one answers one of three questions: how long is a copy fresh, who may store it, and what must happen once it's stale. Most mistakes come from mixing up which directive answers which question.
How long: max-age and s-maxage
max-age=N says the response is fresh for N seconds after the origin produced it. Every cache, private and shared, may serve it without asking for that long. A year is 31536000 seconds and is the usual ceiling.
- Use it for anything whose content at that URL won't change for N seconds: hashed bundles (a year), a logo (a day), a public price list (a minute).
- Mistake: a long
max-ageon a URL whose content does change, like/main.js. Once a browser has it, you can't take it back. No purge reaches the user's disk; you wait out the lifetime or change the URL.
s-maxage=N is a lifetime for shared caches only. A CDN uses it instead of max-age; browsers ignore it. That lets you say "the CDN may keep this for 5 minutes, but browsers must check every time": Cache-Control: max-age=0, s-maxage=300. It also implies that once the shared copy is stale, the CDN must revalidate before reusing it.
- Use it when the CDN can be purged (you control it) but browsers can't: short or zero
max-age, longers-maxage. - Mistake: expecting
s-maxageto make anything faster in the browser. It doesn't touch the browser cache at all.
Who may store it: no-store, private, public
no-store means no cache may keep a copy, anywhere. Every request goes to the origin and downloads the full body. It's the only directive that truly means "don't cache".
- Use it for responses that must not sit on disk: a bank statement, a one-time token, a page on a shared library computer.
- Mistake: reaching for
no-storewhen you only want users to see fresh data.no-cachegives you that and keeps cheap304s.no-storethrows both away.
private means only a private cache (the user's browser) may store it; CDNs and proxies must not. public means shared caches may store it even in cases where they normally wouldn't, mainly responses to requests that carried an Authorization header. (s-maxage and must-revalidate also allow that, a detail worth knowing before you add them to an authenticated route.)
- Use
privatefor anything built for one user that's fine to keep in their own browser: their profile, their cart count, their dashboard data. - Mistake with
private: thinking it hides data. It's an instruction to caches, not encryption. The data still travels to the browser and sits in its cache. - Mistake with
public: adding it everywhere as if it switched caching on. A200withmax-ageis already cacheable by shared caches.publicon a response to an authenticated request tells a CDN to store one user's response and serve it to others.
What happens when it's stale: no-cache, must-revalidate, immutable
no-cache is the most misunderstood directive on the web. It does not mean "don't cache". It means "you may store this, but you must revalidate with the origin before every use". With an ETag, each use costs one round trip and, when nothing changed, a tiny 304.
- Use it for HTML and other entry points that must always be current but are often unchanged.
- Mistake: sending
no-cachewithout an ETag or Last-Modified. Then there's nothing to compare, and every revalidation is a full200.
must-revalidate says: once this copy is stale, never serve it without a successful revalidation. Caches are normally allowed to serve stale copies in some cases, for example when the origin is unreachable. With must-revalidate they must return an error (504 Gateway Timeout) instead. While the copy is fresh, it changes nothing.
- Use it when a stale answer is worse than an error: a price that must be honoured, a stock level used to accept orders.
- Mistake: thinking it means "always revalidate". That's
no-cache.max-age=600, must-revalidatestill serves fresh hits for 10 minutes.
immutable promises that the content at this URL will never change while it's fresh, so the browser shouldn't revalidate it even when the user presses reload. Without it, a reload in some browsers sends a conditional request for every file on the page, which on a page with 60 files is 60 pointless 304s.
- Use it only with a long
max-ageon a URL that changes whenever its content changes:app.3f9c2b.js. Firefox and Safari honour it; Chrome gets most of the same effect because, since 2017, a normal reload there only revalidates the page itself, not its subresources. - Mistake:
immutableon/app.js. You've promised a file you will change never changes.
no-cache says yes to storing: only no-store stops it.Combining directives, and the request side
Directives combine, and the strictest one wins. no-store, max-age=600 is just no-store. max-age=0, must-revalidate behaves almost exactly like no-cache, and you'll see both in the wild. Unknown directives are ignored, so a typo like max_age=600 silently does nothing and leaves you with heuristic freshness.
Requests can carry Cache-Control too. A normal reload makes the browser send Cache-Control: max-age=0 for the page, which forces revalidation. A hard reload (Shift plus reload) sends Cache-Control: no-cache and skips the cache for everything. That's why "it works after a hard refresh" tells you the server's headers let a stale copy live too long.
Try the builder below: switch directives on and off and read what each combination means for the browser and for the CDN, plus the warnings for combinations that look right but aren't.
Cache-Control: public, max-age=31536000, immutable
- Browser: serves it without asking for 1 year.
- CDN: serves it for 1 year.
publicalso lets it store responses to requests that carriedAuthorization. - Once stale: revalidated with
If-None-MatchorIf-Modified-Since, so an unchanged file costs a304. - On reload: not revalidated while fresh, so a reload sends no request for it.
Watch out:
public: be sure the response is the same for every user. Never on per-user data.
Check your understanding · Exercise 4
Your HTML must always be up to date after a deploy, but you want repeat visits to stay cheap. Which headers fit? Select all that apply.
Serving stale on purpose: stale-while-revalidate and stale-if-error
Plain revalidation has a cost: when a copy goes stale, the next user waits a full round trip, maybe a slow one to an origin across the world, before seeing anything. For lots of content, a copy that's one minute out of date is perfectly fine if it arrives instantly. Two directives from RFC 5861 let you say exactly that.
stale-while-revalidate: answer now, refresh in the background
Cache-Control: max-age=60, stale-while-revalidate=600 sets up three windows. For the first 60 seconds the copy is fresh: plain hits. From 60 to 660 seconds it's stale but usable: the cache answers at once with the stale copy and, in the background, sends a conditional request to refresh it. After 660 seconds it's simply stale, and the next request waits for the network.
max-age=60, stale-while-revalidate=300, no user waits during the first six minutes. The request at 90 s pays nothing and leaves a fresh copy for the next one. The drawing isn't to scale.The request that lands in the middle window gets the old copy, and the one after it gets the refreshed copy. So the user pays nothing for freshness, and the content is at most one request behind. For a news home page that gets a request every second, "one request behind" means about one second behind, with no visitor ever waiting on the origin.
- Where it works. CDNs like Cloudflare, Fastly, CloudFront, and Vercel's edge honour it. Chrome and Firefox honour it in the browser's HTTP cache too. It's widely used at the CDN, where one background refresh serves thousands of users.
- What it's good for. Content where a short delay is fine: article pages, product listings, avatars, a public API that returns trending items.
- What it's bad for. Anything a user just changed. If I edit my profile name and the next page view shows the old name from a stale-while-revalidate window, it looks like my edit failed.
- Not the React library. The
useSWRhook is named after this directive and follows the same idea in JavaScript memory, but it's separate code. The header works with no JavaScript at all.
stale-if-error: a safety net when the origin breaks
Cache-Control: max-age=60, stale-if-error=86400 says: if revalidation fails because the origin returns a 500, 502, 503, or 504, or can't be reached, you may keep serving the stale copy for up to a day. The site degrades to "slightly old" instead of "down".
It's honoured mainly by CDNs; browsers largely ignore it, so don't count on it on the client. Many CDNs offer the same safety net as a setting (Cloudflare's Always Online, Fastly's serve-stale), and some apply it by default. Note how it clashes with must-revalidate: that directive forbids serving stale on error, so the two shouldn't appear together.
# A public article page behind a CDN
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400
# Browser: stale at once (max-age=0). Chrome and Firefox may still show it
# while refreshing in the background, for up to 5 min.
# CDN: fresh for 60 s (s-maxage), then serves stale while refreshing,
# and keeps serving the old copy for a day if the origin is down.
Check your understanding · Exercise 5
A CDN stores a response with max-age=60, stale-while-revalidate=300. Requests arrive at 30 s, 90 s, 91 s, and 500 s after it was stored, and the origin's content changed at 50 s. What happens? Select all that apply.
Choosing headers by resource type
You don't pick a caching policy per site; you pick one per kind of response. Two questions decide almost every case: can the content at this URL ever change? and is it the same for every user? Walk the tree below for any response and you land on a sensible header.
no-store.The pattern behind every fast site: hashed assets plus fresh HTML
Build tools like Vite, webpack, and Next.js name output files after a hash of their content: app.3f9c2b.js, styles.a71e04.css. If one byte of the file changes, the hash and so the URL change. The content at a given URL can then never change, which makes it safe to cache for a year with immutable.
The HTML is the one file that can't be renamed: users type / and /products/42, not /index.8d1f.html. So the HTML gets no-cache and an ETag. Every visit checks it, and it's the HTML that names the current hashed files. After a deploy, the first check returns new HTML pointing at new file names; those are misses, fetched once, then cached for a year.
This splits the work neatly. One small, always-checked file decides which version the user gets; a large set of never-checked files makes the version fast. A repeat visit with no deploy in between costs one 304 for the HTML and nothing else. A deploy that changed one chunk costs the new HTML plus that one chunk.
| Response | Cache-Control | Why |
|---|---|---|
| Hashed JS, CSS, fonts, images from the build | public, max-age=31536000, immutable | The URL changes when the content does, so a copy can never be wrong. |
| HTML pages | no-cache plus an ETag, or max-age=0, s-maxage=N so the CDN can hold it, with a purge on deploy | Must reflect the latest deploy and the latest content; cheap to check. |
| Public API data (product list, trending) | public, max-age=0, s-maxage=30, stale-while-revalidate=60 | Same for everyone, fine to be seconds old; the CDN absorbs the traffic. |
| Per-user API data (cart, profile) | private, no-cache plus an ETag | Never in a shared cache; the browser may keep it and revalidate. |
| Sensitive data (statements, tokens) | no-store | Must not be written to disk on a shared or lost device. |
| User uploads with an id in the URL | public, max-age=31536000, immutable | A new upload gets a new id, so the URL is effectively content-addressed. |
| Images at a stable URL (CMS banner) | public, max-age=3600 plus an ETag | Can change, so a moderate lifetime and cheap revalidation. |
Two special cases. The service worker script, /sw.js, must stay at a stable URL. Browsers now skip the HTTP cache by default when checking it for updates, but serve it with no-cache anyway, so older browsers (which honoured its lifetime for up to 24 hours) also pick up updates promptly. And permanent redirects (301, 308) are cached by browsers, often for a very long time, so don't use them for anything you may change.
Cache busting: getting a new version past a long lifetime
Once a browser holds a copy with a long max-age, nothing you do on the server reaches it. The only way to make it fetch something new is to change the URL. That's cache busting, and there are a few ways to do it.
Decision How should a site make browsers pick up new versions of its JavaScript and CSS?
What we pick: Use content hashes for anything the build produces; every modern bundler does it by default. A path version is a fine fallback for hand-made sites. Use query strings only when you can't change file names, and never as the only defence on a CDN you haven't checked.
One catch with hashed files: keep the old ones around after a deploy. A user who loaded the page before the deploy still has the old HTML in their tab, and when they open a lazy-loaded route, their app asks for checkout.91ab.js. If you deleted it, they get a 404 and the route breaks (webpack reports it as ChunkLoadError). Keep previous builds' files for days or weeks, or catch the error and reload the page.
Check your understanding · Exercise 6
Which of these responses are safe to serve with public, max-age=31536000, immutable? Select all that apply.
Shared caches: CDNs, purging, and surrogate keys
A browser cache helps one user. A CDN cache helps everyone near the same edge server. If a CDN answers 95% of requests (a 95% hit ratio), your origin handles one request in twenty. For the shop from chapter 1 with 1,400 requests per second, that's about 70 reaching your servers instead of 1,400.
The CDN also has one property the browser cache doesn't: you control it. You can't reach into a user's disk and delete a copy, but you can tell your CDN to drop one. That single difference shapes how teams split their headers between the two.
Short in the browser, long at the edge
The common pattern for content that changes at unpredictable times (articles, product pages, public API responses) is: a short or zero browser lifetime, a long CDN lifetime, and a purge whenever the content changes. Browsers keep asking the CDN, which is close and fast; the CDN almost never asks the origin, because it's told the moment something changes.
You can express the split with s-maxage as seen earlier, or with a newer, clearer header aimed only at CDNs. CDN-Cache-Control, defined in RFC 9213, uses the same directives as Cache-Control, but only CDNs read it; browsers ignore it. A CDN that finds it uses it instead of Cache-Control for its own copy.
Cloudflare supports it, and Vercel uses it alongside its own Vercel-CDN-Cache-Control. Fastly has long had the same idea with Surrogate-Control, which it removes before the response reaches the browser.
HTTP/1.1 200 OK
Cache-Control: public, max-age=0, must-revalidate
CDN-Cache-Control: max-age=86400
Surrogate-Key: product-42 category-shoes home
ETag: "p42-v19"
Purging: by URL, by tag, or everything
- By URL. Purge
/products/42. Simple, but a product appears on many pages: its own page, its category, search results, the home page. You have to know every URL, and with query strings andVarythere may be many copies of each. - By tag (surrogate key). The origin labels each response with the things it contains:
Surrogate-Key: product-42 category-shoeson Fastly,Cache-Tagon Cloudflare,Edge-Cache-Tagon Akamai. When product 42 changes, you purge the tagproduct-42, and every cached page that showed it is dropped, whatever its URL. - Everything. Purge the whole zone. Safe and simple after a big change, but every edge server goes empty at once and all traffic hits the origin until the caches refill.
Surrogate-Key or Cache-Tag. One purge by tag drops every page that showed product 42, without anyone listing URLs.Some CDNs offer a soft purge: instead of deleting copies, it marks them stale. With stale-while-revalidate or stale-if-error, the CDN can still serve the old copy for a moment while it fetches the new one, so a purge never turns into a spike of slow requests or errors.
Where purging goes wrong
- Purging before the origin is ready. If you purge, then a request arrives before your database replica or origin cache has the new data, the CDN fetches and stores the old version again, now with a fresh lifetime. Purge after the write is visible, and consider purging a second time a few seconds later.
- Forgetting the browser. A CDN purge doesn't touch copies in browsers. If the browser lifetime is an hour, users can see old content for up to an hour after a perfect purge. That's why the browser lifetime is kept short when you rely on purges.
- The thundering herd. After a purge-everything or a deploy, thousands of edge servers miss at once. CDNs reduce this with request collapsing: many simultaneous misses for one key become one request to the origin. Shielding (a middle CDN tier in front of the origin) helps too.
Check your understanding · Exercise 7
Product 42's price changes. Pages are served with Cache-Control: public, max-age=300 and Surrogate-Key: product-42, and you purge the tag immediately. Which statements are true? Select all that apply.
Privacy and security: what must never reach a shared cache
Getting caching wrong in the slow direction costs money. Getting it wrong in the other direction can leak one user's data to strangers. The rule is short: a response built for one user must never be stored in a shared cache. The ways teams break it are worth knowing one by one.
How a leak happens
Imagine /account returns the logged-in user's name, address, and last orders. The origin sends no Cache-Control. Someone adds a CDN rule "cache everything for 60 seconds" to survive a traffic spike. Now the first user to open /account after each minute has their page stored at the edge, and every other user who opens /account in that minute sees it.
This isn't hypothetical. On Christmas Day 2015, a caching configuration change made during a traffic attack caused Steam's store to show some users' account pages, with personal details, to other users. Tens of thousands of users were affected in under two hours. The fix was the boring one: personal responses marked so no shared cache stores them, and CDN rules that respect that.
The rules
- Mark personal responses
private(orno-store). Do it in the origin, on every authenticated route, as a default in your framework or middleware, so a forgotten route fails safe. - Never
publicon authenticated responses.publicis precisely the switch that lets a shared cache store a response to a request withAuthorization.s-maxageandmust-revalidateallow it too, so keep them off these routes as well. - Never let a shared cache store a response that sets a cookie. A shared copy carrying
Set-Cookiecould hand one user's session to everyone. Many CDNs skip such responses by default, but don't rely on a default you haven't checked. - Use
no-storefor sensitive data, so it isn't even written to the disk of a shared computer. - Keep CDN "cache everything" rules narrow: a path prefix like
/assets/, never the whole site.
Why Vary: Cookie is not a fix
A tempting idea is to let a CDN cache personal pages with Vary: Cookie, so each user gets the copy that matches their cookies. It fails in three ways.
- Hit rate near zero. Cookie strings are close to unique per user, and analytics scripts change them constantly. The CDN stores thousands of copies and reuses almost none.
- CDNs may ignore it. Several CDNs honour only a few
Varyvalues. Cloudflare, for example, ignores most of them, soVary: Cookiemay not separate users at all. - Wrong tool. Personal data doesn't belong in a shared cache even when the key is right. Cache the shared shell of the page at the edge, and load the personal part with a
privaterequest.
Web cache deception
Some CDNs decide what to cache by file extension: "cache anything ending in .css". An attacker sends a victim a link to /account/profile.css. The origin's router ignores the unknown suffix and returns the victim's account page; the CDN sees .css and caches it; the attacker opens the same URL and reads it. The defence is the same as before: the origin marks personal responses private, the CDN obeys origin headers instead of guessing by extension, and unknown paths return 404.
Other things that break in production
- Cached errors. A
404can be cached heuristically, and many CDNs cache errors briefly. A file requested a second before it was uploaded can stay "missing" for minutes. SendCache-Control: no-storeor a shortmax-ageon errors. - Redirect loops that stick. A wrong
301is cached by browsers for a long time. Test redirects as302first. - A CORS response cached for the wrong origin. If the server reflects the request's
OriginintoAccess-Control-Allow-Origin, it must sendVary: Origin, or a cache will serve one site's CORS answer to another and requests start failing for no clear reason.
Check your understanding · Exercise 8
A dashboard API /api/me/summary is slow, and a teammate proposes CDN caching. Which plans are safe? Select all that apply.
Debugging caching, and the cache that isn't HTTP
Caching bugs are hard to see because a cache hit looks just like a network response to your code. DevTools and a few response headers show you which layer answered and why. Learn to read them and most caching questions take a minute.
Reading the Network panel
304 means a revalidation, and a full size means a download. Then open the request and check Age and the CDN status header.- Size column.
(memory cache),(disk cache), or(ServiceWorker)means no network request for the body. A small number like312 Bnext to status304means a revalidation. A full size like18.2 kBmeans a download. - Status.
200with a cache label is a fresh hit.304is a successful revalidation.200with bytes is a miss or a changed file. - Time. A hit takes about 0 to 5 ms; a
304takes a full round trip; that's the difference you're paying for on everyno-cachefile. - Disable cache. The checkbox at the top makes every request skip the browser cache, but only while DevTools is open. Leave it on by accident and you'll think your caching is broken.
- Response headers. Click a request to see
Cache-Control,ETag,Age, and the CDN's own status header.
For CDN behaviour, the response headers tell you more than the panel. Age: 1840 means the copy has been in a shared cache for 1,840 seconds. Cloudflare adds CF-Cache-Status: HIT (or MISS, EXPIRED, REVALIDATED, DYNAMIC, which means it didn't try to cache). Fastly, CloudFront, and others use X-Cache: HIT or similar, and RFC 9211 defines a standard Cache-Status header that newer CDNs send.
$ curl -sI https://shop.example/assets/app.3f9c2b.js
HTTP/2 200
cache-control: public, max-age=31536000, immutable
age: 523114
cf-cache-status: HIT
etag: "3f9c2b"
$ curl -sI https://shop.example/ -H 'If-None-Match: "html-v88"'
HTTP/2 304
cache-control: no-cache
etag: "html-v88"
curl -I shows the headers with no browser cache in the way: the bundle is a long-lived CDN hit, and the HTML revalidates to a 304.A checklist when "users see the old version": check the HTML's Cache-Control (is it really no-cache?), check whether the asset URLs changed (are names hashed?), check Age and the CDN status on the HTML (is the CDN holding it?), and check for a service worker serving an old copy from its own cache (DevTools, Application panel).
bfcache: a different cache entirely
The back/forward cache (bfcache) keeps a whole page in memory when you navigate away: the DOM, the JavaScript heap, scroll position, everything. Press Back and the page is restored instantly, exactly as you left it, without running any loading code. It's not the HTTP cache: no requests, no max-age, no 304s.
| Question | HTTP cache | bfcache |
|---|---|---|
| What's stored | Individual responses (bytes plus headers) | A whole live page, frozen |
| Used for | Any request on any navigation | Only Back and Forward navigations |
| Controlled by | Cache-Control, ETag, Vary | Whether the page is eligible; no headers set a lifetime |
| Common blockers | no-store, missing headers | unload handlers, some open connections, and historically no-store on the page |
| How code notices | It doesn't; a hit looks like a response | The pageshow event with event.persisted set to true |
The two meet in one place: Cache-Control: no-store on the HTML long made a page ineligible for bfcache in Chrome. Chrome has been relaxing this for pages where it's safe, such as when no cookies changed, but the lesson stands: no-store on a page has costs beyond the HTTP cache. DevTools has a Back/forward cache test in the Application panel that lists exactly what blocks a page.
If a restored page shows stale data (an old cart count), listen for pageshow and refresh that piece of data when event.persisted is true. Don't disable bfcache to fix it; instant Back navigations are among the biggest speed wins a site can get.
Check your understanding · Exercise 9
After a deploy, some users still see the old app. DevTools on an affected machine shows index.html with (disk cache) and status 200. What's likely? Select all that apply.
In real products, and in the interview
Everything so far comes together in a handful of patterns that real stacks ship by default. Knowing them lets you check a site's headers in a minute and explain your own choices in an interview with confidence.
What real stacks do
- Next.js serves everything under
/_next/static/(hashed by the build) withpublic, max-age=31536000, immutable. Dynamically rendered pages default to headers that stop shared caching, and pages using incremental static regeneration are sent with ans-maxageplusstale-while-revalidate, so the CDN serves the page instantly and regenerates it in the background. - Vite, webpack, Parcel, and esbuild all put content hashes in output file names, so the "hashed assets plus
no-cacheHTML" pattern is the default shape of a modern deploy. Your job is to make sure the server and CDN send the matching headers. - Wikipedia serves logged-out readers almost entirely from its edge caches and purges a page's cached copies when it's edited. Logged-in users, whose pages are personal, bypass those caches. It's the split-policy pattern at huge scale: shared content cached hard and purged on change, personal content never shared.
- News and commerce sites on Fastly or Cloudflare tag pages with surrogate keys for each article or product they contain, set long CDN lifetimes, and purge by tag from their CMS on publish. The browser gets
max-age=0or a few seconds, so a purge is visible to readers almost immediately.
When to bring it up in a system design interview
HTTP caching belongs in almost every frontend system design answer, usually in the performance or delivery part. Bring it up when the interviewer asks about load time on repeat visits, slow networks, deploys, CDN use, traffic spikes, or cost. Don't spend long on it unless they dig in; one clear sentence and a small table often earn the credit.
- What to draw. The chain of caches (browser, service worker if any, CDN, origin) and a small table: resource type,
Cache-Control, why. That's enough to show you think in layers. - What to say first. Hashed assets get a year and
immutable; HTML getsno-cachewith an ETag; per-user API data isprivate; public API data gets a short CDN lifetime withstale-while-revalidate. - What to say next, if asked. How deploys are picked up (the HTML names new hashes), how content changes reach users (purge by surrogate key, short browser lifetime), and what you'd measure (CDN hit ratio, origin requests per second, repeat-visit LCP).
| Level | What a strong answer covers |
|---|---|
| Junior | The difference between a fresh hit, a 304, and a miss. Hashed file names with long caching, no-cache HTML. Knows no-cache isn't no-store. |
| Mid | ETags and conditional requests, private vs public, s-maxage and the CDN, stale-while-revalidate, Vary: Accept-Encoding, the chunk-404 problem after deploys. |
| Senior | Split browser and CDN policies with purge by surrogate key, cache key normalization, hit-ratio math for origin sizing, privacy defaults (private unless opted in), cache deception, request collapsing after purges, how a service worker and bfcache interact with all of it. |
Think first
You're designing a news feed. The interviewer asks: "The feed API is hit 50,000 times a second at peak. How would caching help?" What do you say?
Split the response. The ranked list of story ids for logged-out users, or per region, is shared: serve it with public, max-age=0, s-maxage=15, stale-while-revalidate=30 so the CDN answers almost all of those 50,000 requests and the origin sees roughly one request per key per 15 seconds per edge region.
The story bodies are content-addressed by id and version (/stories/123?v=7 or /stories/123/v7), so they can be cached for a long time and shared by every feed that includes them.
Per-user parts (likes, seen state, the personalized ranking) come from a separate private, no-cache call with an ETag, so they're never shared and are cheap to recheck.
Finally, tag shared responses with surrogate keys (story ids) so an edited or deleted story can be purged from every cached feed within seconds.
Summary
- Every request ends as a fresh hit (no network), a revalidation (one round trip, a tiny
304), or a miss (everything). Headers exist to push requests towards fresh hits. - A response passes through several caches: memory, service worker, the browser's HTTP cache, CDNs, and the origin. Private caches serve one user; shared caches serve many.
- The cache key is the URL, plus any request headers named in
Vary(and, in browsers, the top-level site). Query strings and broadVaryvalues quietly multiply misses. no-cachemeans "revalidate before use", not "don't store"; onlyno-storestops caching.must-revalidateonly forbids serving stale copies.- Hashed assets get
max-age=31536000, immutable; HTML getsno-cachewith an ETag. The HTML picks the version, the hashes make it fast. - Always send
Cache-Control: without it, browsers guess a lifetime of 10% of the file's age, and users get old files after deploys. - Keep browser lifetimes short and CDN lifetimes long where content changes, and purge the CDN by surrogate key. You can purge a CDN, never a browser.
- Personal data never goes in a shared cache:
privateorno-storeby default on authenticated responses, nopublic, and no reliance onVary: Cookie.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
What's the difference between no-cache and no-store?
no-store means no cache may keep a copy at all: every request downloads the full response. It's for sensitive data that shouldn't be written to disk.
no-cache means a cache may store the response but must revalidate it with the origin before each use. With an ETag, an unchanged response costs one round trip and a 304 of a few hundred bytes.
So for HTML that must always be current, no-cache is right and no-store is wasteful. The names are misleading; no-cache really means "check before use".
Question 2
How do you make sure users get new JavaScript right after a deploy, while still caching it aggressively?
Put a content hash in every built file name, like app.3f9c2b.js, and serve those with public, max-age=31536000, immutable. A changed file gets a new URL, so a cached copy is never wrong.
Serve the HTML, which names the current files, with no-cache and an ETag. Each visit checks it; after a deploy it returns new HTML with new file names, and only the changed files are downloaded.
Keep old hashed files on the server for a while, so tabs still running the old HTML can lazy-load their chunks without 404s, and purge the HTML at the CDN on deploy if the CDN caches it.
Question 3
Walk me through what happens when a cached response with an ETag goes stale.
The cache doesn't delete it. On the next request it sends a conditional request with If-None-Match and the stored ETag (and If-Modified-Since if it kept a Last-Modified).
If the server's current ETag matches, it answers 304 Not Modified with headers and no body. The cache updates its stored headers, resets the copy's age, and serves the stored body.
If it doesn't match, the server sends 200 with the new body and new ETag, which replaces the stored copy. With stale-while-revalidate, the cache can serve the stale copy at once and do this check in the background.
Question 4
Your CDN occasionally shows one user's account page to another. What's going on and how do you fix it?
A shared cache is storing a personal response. Usually the origin sends no Cache-Control (or public) on the route, and a CDN rule caches it anyway, by path or by file extension.
Fix it at the origin first: private, no-cache (or no-store) on every authenticated response, as a framework default, and never cache responses that carry Set-Cookie. Then make the CDN respect origin headers and narrow any "cache everything" rules to static paths.
Don't patch it with Vary: Cookie: hit rates collapse, and some CDNs ignore most Vary values. Purge the affected URLs straight away, and check for web cache deception paths like /account/x.css.
Question 5
When would you use s-maxage or CDN-Cache-Control instead of just max-age?
When the browser and the CDN should keep a copy for different lengths of time. The usual case is content that changes unpredictably, such as articles or product pages.
You can purge a CDN but not a browser. So you give the CDN a long lifetime (s-maxage=86400, or CDN-Cache-Control: max-age=86400) and purge on change, and give browsers max-age=0 or a few seconds so they keep asking the CDN.
CDN-Cache-Control is the clearer, newer way (RFC 9213): only CDNs read it, so the main Cache-Control can be written purely for browsers.
Practice it
These design problems lean on http caching.
References
- RFC 9111: HTTP Caching The current standard: freshness, validation, and every directive.
- MDN: HTTP caching A clear guide with examples of each pattern.
- MDN: Cache-Control Every directive, with browser support.
- web.dev: Prevent unnecessary network requests with the HTTP Cache Practical header choices by resource type.
- RFC 5861: HTTP Cache-Control Extensions for Stale Content Defines
stale-while-revalidateandstale-if-error. - RFC 9213: Targeted HTTP Cache Control Defines
CDN-Cache-Control. - web.dev: Back/forward cache How bfcache works, what blocks it, and how to test it.
Read next
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Cache invalidationHow every copy of a piece of data, from the server cache to the CDN to the open tab, learns that it is out of date: by time, by event, or by a new name, in the right order and without a stale read sneaking back in.
- Service worker cachingUsing a service worker as a programmable cache between your page and the network, so you choose per request whether to answer from a stored copy, the network, or both: the basis of instant loads and offline apps.
- DNS, CDNs, and the edgeHow a name becomes an address, how that address leads to a server a few milliseconds from the user instead of an ocean away, and what code and files a frontend should run and keep there.
- From URL to pixelsEverything that happens between pressing Enter and seeing a page: DNS, TCP, TLS, the HTTP exchange, redirects, parsing, and subresources, with where the time goes on a real phone, so you know which part to fix when a page is slow.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.