cf.completefrontend
My progress
FUNDAMENTALS · MODULE 9

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.

A bar chart of one repeat visit to a shop page with 61 files, drawn to scale. With no caching headers: 1,600 KB downloaded in 61 requests. With every file no-cache and an ETag: 48 KB, but still 61 round trips, because every file is checked. With hashed assets cached for a year and the HTML no-cache: 30 KB and 1 round trip, only for the changed HTML.one repeat visit, bytes to scaleno caching headers1,600 KB, 61 requestseverything no-cache, with ETags48 KB, 61 round tripshashed assets + no-cache HTML30 KB, 1 round trip
Same page, same server, different headers. Revalidating everything saves bytes but not round trips; long lifetimes on hashed files plus a 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.

  1. Fresh hit. The cache has a copy that is still within its allowed lifetime. It answers at once. No network at all.
  2. 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 answers 304 Not Modified with no body, and the cache reuses its copy. One round trip, almost no bytes.
  3. 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.

A stack of five layers, nearest first. In the browser, which are private caches for one user: the memory cache for the current page at 0 milliseconds, the service worker running your code at 1 to 50 milliseconds, depending on whether it is already running, and the HTTP disk cache at a few milliseconds. On the network, which are shared: a CDN edge at 10 to 50 milliseconds, then the origin server at 100 to 300 milliseconds.Browser: private cachesNetwork: shared cachesMemory cachethis page only: (memory cache)0 msService workeryour code: (ServiceWorker)1 to 50 msHTTP cacheon disk: (disk cache)a few msCDN edgeshared by many users10 to 50 msOriginbuilds the response100 to 300 msnearfar
A request walks down until some layer has a usable copy, and the response walks back up, stored by each layer allowed to keep it. 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-Control steers 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 valueWhat it meansEffect on hit rate
Accept-EncodingThe body depends on compression (gzip, br, zstd).Fine: there are only a few common values, and CDNs normalize them.
Accept-LanguageThe 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.
CookieThe 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
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
The response stores itself with a 10-minute lifetime and two validators.
The response headers of /api/menu.json, one per line, with notes. HTTP/1.1 200 OK: a full response, a miss. Cache-Control: max-age=600: fresh for 600 seconds, then check. ETag "m-7f3a": the version tag to ask with later. Last-Modified 08:40 GMT: an older validator with one-second steps. Date 09:00 GMT: when the origin made it. Content-Length 18240: the 18 KB a 304 saves.HTTP/1.1 200 OKa full response: a missCache-Control: max-age=600fresh for 600 s, then checkETag: "m-7f3a"version tag to ask withLast-Modified: 08:40 GMTolder validator, 1 s stepsDate: 09:00 GMTwhen the origin made itContent-Length: 18240what a 304 saves: 18 KB
One header sets the lifetime, two give the cache something to ask with later. Dates are shortened here; real ones are full HTTP dates like 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:

http
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
The question and the short answer. The 304 has no body.

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.

A sequence diagram between the page, the browser HTTP cache, and the server. At 09:05 the copy is 300 seconds old and fresh: the page asks for /api/menu.json and the cache answers from disk with 0 bytes downloaded; the server sees nothing. At 09:15 the copy is 900 seconds old and stale: the cache sends If-None-Match "m-7f3a" to the server, the server answers 304 with no body, about 250 bytes, and the cache serves its stored copy with its age reset.PageHTTP cacheServer09:05: 300 s old, freshGET /api/menu.jsonfrom disk, 0 bytes09:15: 900 s old, staleGET /api/menu.jsonIf-None-Match: "m-7f3a"304, no body, 250 Bstored copy, age 0
A fresh hit stays on the device. A stale copy costs one round trip, but a 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, and Vary. Caches update the stored headers from it, so a 304 without Cache-Control can 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.

Cache-Control:

Clock: 0 s. The server has v1 (18.2 KB), last modified 10 days ago. Nothing stored.

The last 0 requests with Cache-Control max-age=60 and validators: none yet. In total 0.0 KB downloaded and 0 waits on the network.no requests yetdownloaded 0.0 KB, waited 0 times
Each box is one request, oldest on the left: hit (no request), 304 (one round trip, no body), stale (served at once, refreshed in the background), or miss and 200 (full download). Under it: when, and the version you saw.

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-age on 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, longer s-maxage.
  • Mistake: expecting s-maxage to 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-store when you only want users to see fresh data. no-cache gives you that and keeps cheap 304s. no-store throws 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 private for 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. A 200 with max-age is already cacheable by shared caches. public on 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-cache without an ETag or Last-Modified. Then there's nothing to compare, and every revalidation is a full 200.

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-revalidate still 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-age on 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: immutable on /app.js. You've promised a file you will change never changes.
A matrix of eight Cache-Control directives against six questions. max-age: browser and CDN may store it, and it sets a lifetime. s-maxage: the CDN may store it and it sets the CDN's lifetime; browsers ignore it. no-cache: browser and CDN may store it, but it is checked before every use, so no unchecked copy is served on error either. no-store: neither may store it. private: the browser may store it, the CDN may not. public: both may store it. must-revalidate: no stale copy is served on error. immutable: no check on reload.browserstoresCDNstoressets alifetimechecksevery useno staleon errorno checkon reloadmax-ageyesyesyess-maxageyesyesno-cacheyesyesyesyesno-storenonoprivateyesnopublicyesyesmust-revalidateyesimmutableyes
Each directive answers one narrow question, and an empty box means "not its job". Notice that 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.

Start from:

Cache-Control: public, max-age=31536000, immutable

  • Browser: serves it without asking for 1 year.
  • CDN: serves it for 1 year. public also lets it store responses to requests that carried Authorization.
  • Once stale: revalidated with If-None-Match or If-Modified-Since, so an unchanged file costs a 304.
  • 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.

A timeline for a response with max-age=60 and stale-while-revalidate=300. From 0 to 60 seconds it is fresh. From 60 to 360 seconds it is stale but usable: served at once while a background request refreshes it. After 360 seconds it is too old. A request at 30 seconds is a fresh hit; a request at 90 seconds gets the old copy at once and triggers a background refresh; a request at 400 seconds, if nothing refreshed it, waits for the network.freshstale, served while refreshingtoo old0 s60 s360 s30 s: fresh hit90 s: old copy at once,refreshed in background400 s: waits
With 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 useSWR hook 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.

http
# 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.
A common edge policy: the CDN rarely asks the origin, and no visitor waits for a refresh.

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.

A decision tree. First question: can the content at this exact URL change? No: it is a hashed or content-addressed asset, so use public, max-age=31536000, immutable. Yes: is it the same for every user? Yes: it is shared, like HTML or a public API, so the browser revalidates it with an ETag on every use while the CDN holds it, through s-maxage or CDN-Cache-Control, and is purged when it changes. No: is it sensitive? Yes: no-store. No: it is per-user data, so use private, no-cache with an ETag.Can the content change?at this exact URLnohashed assetmax-age=31536000, immutableyesSame for every user?no cookies, no authyesHTML, public APIETag; CDN holds it, purgesnoSensitive?statements, one-time tokensyesno-storenever written to disknoper-user dataprivate, no-cache + ETag
Two questions settle most responses: can the content at this URL change, and is it the same for everyone? Only per-user data needs the third, and only truly sensitive data gets 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.

ResponseCache-ControlWhy
Hashed JS, CSS, fonts, images from the buildpublic, max-age=31536000, immutableThe URL changes when the content does, so a copy can never be wrong.
HTML pagesno-cache plus an ETag, or max-age=0, s-maxage=N so the CDN can hold it, with a purge on deployMust 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=60Same for everyone, fine to be seconds old; the CDN absorbs the traffic.
Per-user API data (cart, profile)private, no-cache plus an ETagNever in a shared cache; the browser may keep it and revalidate.
Sensitive data (statements, tokens)no-storeMust not be written to disk on a shared or lost device.
User uploads with an id in the URLpublic, max-age=31536000, immutableA 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 ETagCan 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?

Content hash in the file name

The build writes app.3f9c2b.js, with the hash of the file's bytes in the name.

HTML is served no-cache and references the current names.

Pros

  • Only files that changed get new URLs, so a deploy invalidates only what changed.
  • Safe to cache for a year with immutable.
  • Works with every CDN and proxy; the name is just a path.

Cons

  • Needs a build step that rewrites references (every modern bundler does).
  • Old files must stay on the server for a while after a deploy.

Version in a query string

/app.js?v=482, with the number bumped on every release.

Pros

  • Easy to add to a site with no bundler.

Cons

  • A version per release invalidates every file on every deploy, changed or not.
  • Some CDNs can be set to ignore query strings in the cache key; then busting silently fails.
  • Easy to forget to bump, and the file at /app.js can't be cached with immutable.

Version in the path

/v482/app.js, with every asset under a release folder.

Pros

  • Works with any cache; easy to roll back by pointing HTML at the old folder.

Cons

  • Like a query-string version, every deploy invalidates every file.

No long caching: everything no-cache

All files revalidated on every use with ETags.

Pros

  • Always correct, no build changes.

Cons

  • A round trip per file per page view; on a page with 60 files that's real latency, even when every answer is 304.

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.

Two halves. Before the deploy: index.html, served no-cache, is revalidated and gets a 304; it names app.3f9c2b.js, cached for a year as immutable, which loads from disk in a millisecond or two. After the deploy: index.html revalidates and gets a 200 with new HTML, which names app.7d41e0.js, a URL the browser has never seen, so it is downloaded once. The old app.3f9c2b.js stays on disk, never asked for again.before the deployindex.htmlno-cache: 304app.3f9c2b.jsmax-age=1 year, immutablefrom disk: 1 to 2 msafter the deployindex.htmlchanged: 200, new HTMLapp.7d41e0.jsnew URL: downloaded oncethen cached for a yearold app.3f9c2b.js: never asked for
The HTML is the only file checked on every visit. Everything it names can be cached for a year, because new code always arrives under a new name.

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
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"
Browsers revalidate every time; the CDN keeps it for a day, and it can be purged by any of three tags.

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 and Vary there 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-shoes on Fastly, Cache-Tag on Cloudflare, Edge-Cache-Tag on Akamai. When product 42 changes, you purge the tag product-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.
A graph of surrogate keys and the cached pages tagged with them. product-42 tags /products/42, /shoes, and the home page. category-shoes tags /shoes and /search?q=boots. home tags the home page. Purging the key product-42 drops /products/42, /shoes, and the home page; /search?q=boots stays cached because it does not show product 42.tags (surrogate keys)cached pagesproduct-42category-shoeshome/products/42/shoes//search?q=boots
The origin labels each page with what it shows, using 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 (or no-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 public on authenticated responses. public is precisely the switch that lets a shared cache store a response to a request with Authorization. s-maxage and must-revalidate allow 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-Cookie could 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-store for 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.

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.

  1. 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.
  2. CDNs may ignore it. Several CDNs honour only a few Vary values. Cloudflare, for example, ignores most of them, so Vary: Cookie may not separate users at all.
  3. 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 private request.

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 404 can be cached heuristically, and many CDNs cache errors briefly. A file requested a second before it was uploaded can stay "missing" for minutes. Send Cache-Control: no-store or a short max-age on errors.
  • Redirect loops that stick. A wrong 301 is cached by browsers for a long time. Test redirects as 302 first.
  • A CORS response cached for the wrong origin. If the server reflects the request's Origin into Access-Control-Allow-Origin, it must send Vary: 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

A sketch of the DevTools Network panel on a repeat visit, with columns Name, Status, Size, and Time. index.html: status 304, 312 bytes, 48 milliseconds: a revalidation. app.3f9c2b.js: 200 from disk cache in 2 milliseconds. logo.svg: 200 from memory cache in 0 milliseconds. menu.json: 200, 18.2 kilobytes, 96 milliseconds: a full download. offline.json: 200 from the service worker in 4 milliseconds.DevTools: NetworkNameStatusSizeTimeindex.html304312 B48 msapp.3f9c2b.js200(disk cache)2 mslogo.svg200(memory cache)0 msmenu.json20018.2 kB96 msoffline.json200(ServiceWorker)4 ms
Read the Size column first: a cache name means no download, a few hundred bytes with 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 like 312 B next to status 304 means a revalidation. A full size like 18.2 kB means a download.
  • Status. 200 with a cache label is a fresh hit. 304 is a successful revalidation. 200 with bytes is a miss or a changed file.
  • Time. A hit takes about 0 to 5 ms; a 304 takes a full round trip; that's the difference you're paying for on every no-cache file.
  • 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.

text
$ 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.

QuestionHTTP cachebfcache
What's storedIndividual responses (bytes plus headers)A whole live page, frozen
Used forAny request on any navigationOnly Back and Forward navigations
Controlled byCache-Control, ETag, VaryWhether the page is eligible; no headers set a lifetime
Common blockersno-store, missing headersunload handlers, some open connections, and historically no-store on the page
How code noticesIt doesn't; a hit looks like a responseThe 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) with public, max-age=31536000, immutable. Dynamically rendered pages default to headers that stop shared caching, and pages using incremental static regeneration are sent with an s-maxage plus stale-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-cache HTML" 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=0 or 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 gets no-cache with an ETag; per-user API data is private; public API data gets a short CDN lifetime with stale-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).
LevelWhat a strong answer covers
JuniorThe 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.
MidETags and conditional requests, private vs public, s-maxage and the CDN, stale-while-revalidate, Vary: Accept-Encoding, the chunk-404 problem after deploys.
SeniorSplit 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?

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 broad Vary values quietly multiply misses.
  • no-cache means "revalidate before use", not "don't store"; only no-store stops caching. must-revalidate only forbids serving stale copies.
  • Hashed assets get max-age=31536000, immutable; HTML gets no-cache with 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: private or no-store by default on authenticated responses, no public, and no reliance on Vary: 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?

Question 2

How do you make sure users get new JavaScript right after a deploy, while still caching it aggressively?

Question 3

Walk me through what happens when a cached response with an ETag goes stale.

Question 4

Your CDN occasionally shows one user's account page to another. What's going on and how do you fix it?

Question 5

When would you use s-maxage or CDN-Cache-Control instead of just max-age?

Practice it

These design problems lean on http caching.

References

Read next