◐ ai-teacher

🚀 Deployment & Ops · Chapter 3

Cache-Control — the header that decides if users see your fix

Caching is the fastest optimisation there is and the most expensive mistake. The difference is one header, decided once, at deploy time.

Study this lesson free in the app →

The basics

Cache-Control is the HTTP response header that tells every cache between your server and the user — browser, CDN, proxy — whether it may keep a copy of a file, and for how long it may use that copy without asking again.

The problem it solves: downloading the same CSS on every page view is slow and wasteful, so caches keep copies. But a copy that's kept too long means users don't see your fix — sometimes for a year, with no way for you to reach them.

The mental model: a cache is keyed by URL. Either the URL changes whenever the content changes (a hash in the filename) — then cache it forever — or the URL stays the same, and the cache must revalidate each time: ask "still the same?" and get a tiny 304 Not Modified back if so. Anything in between is where stale-site bugs live.

Words you must know 5

max-age
seconds a copy is fresh and used without asking
no-cache
keep it, but revalidate before every use (not "don't cache")
no-store
keep no copy at all
ETag / 304
the fingerprint sent back to check freshness, and the bodyless "still good" reply
immutable
never revalidate; only safe with a content-hashed filename

Explained

You fix a bug. You deploy. You refresh — still broken.

Not because the deploy failed. Because a machine between you and the server was told, in writing, that the old file was good for a year. And it believed you.

A cache is anything that keeps a copy so it does not have to ask again. Between your server and a user there are usually four:

  1. The browser's disk cache
  2. The service worker, if the app installed one
  3. The CDN edge node nearest the user
  4. Any proxy in between

Cache-Control is how the server tells all of them what they are allowed to do. Get it right and the site is fast and correct. Get it wrong in one direction and it is slow; wrong in the other and users are stuck on a broken version with no way to recover.

The directives worth knowing

max-age=N — fresh for N seconds. During that window the cache does not even ask.

no-cache — badly named. It does not mean don't cache; it means cache it, but revalidate before every use. The check is cheap: the browser sends the ETag and gets back 304 Not Modified with no body if nothing changed.

no-store — genuinely do not keep a copy. For anything sensitive.

must-revalidate — once stale, you must check; never serve stale on a network error.

immutable — this will never change, do not even revalidate on refresh. Only safe with a content hash in the filename.

public / private — may a shared cache (a CDN) store it, or only the user's own browser? Anything user-specific must be private.

Standard advice is to cache assets for a year. This project deliberately does not — and the reason is a property of this codebase.

The usual pattern relies on content hashing: a build tool renames app.css to app.9f2b1c.css, so a changed file is a different URL and can safely be cached forever.

This app has no build step. assets/css/app.css is always assets/css/app.css. So caching it for a year would mean a phone that loaded the old CSS would never get a fix — and there would be no way to force it short of changing the filename by hand.

must-revalidate costs one cheap conditional request per file per visit and returns a bodyless 304 when nothing changed. The right cache policy is a consequence of whether your filenames change.

That trade-off flips completely once volume goes up.

At a few hundred visits a day, a revalidation request per asset is invisible. At ten million, it is ten million round trips to your origin that produce nothing but 304s.

At that scale you do the work to earn long caching:

The pattern is the same everywhere in systems work: the cheap correct thing until the volume justifies the expensive fast thing.

The service worker is a fifth cache you wrote yourself

Cache-Control does not govern a service worker — your own code does. This app is network-first, which is why a new lesson appears without any cache-busting:

fetch(e.request)
  .then((res) => {
    const copy = res.clone();
    caches.open(CACHE).then((c) => c.put(e.request, copy));
    return res;                       // fresh wins whenever the network works
  })
  .catch(() => caches.match(e.request));  // offline falls back to the copy

The opposite choice, cache-first, is faster and is how you strand users on a broken build. If you pick it, you need a versioned cache name and an activate handler that deletes the old one — which is exactly what the CACHE constant in sw.js is for.

Thinking no-cache means no caching. It means revalidate every time. no-store is the one that means no copy.

immutable without a content hash. You have now made a file uneditable for its entire max-age, for every user who loaded it.

public on a user-specific response. A shared CDN caches one user's data and serves it to the next. This is a real, recurring, serious bug.

Testing with a hard refresh. ⇧⌘R bypasses caches, so it tells you nothing about what a normal visitor sees. Test in a private window, or with curl -I.

Forgetting the service worker exists. It sits in front of everything and obeys none of these headers.

Cache forever, or revalidate every time. The middle is where the bugs live.

Hashed filename → immutable, cache for a year. Stable filename → must-revalidate.

no-cache means check first. no-store means do not keep it.

Words to know

ETagA fingerprint of a response. The client sends it back so the server can reply 304 Not Modified with no body.
304 Not ModifiedA response with headers but no body, meaning "your copy is still good". This is what revalidation costs.
Content hashingPutting a hash of the file contents in its filename, so changing the file changes the URL.
EdgeThe CDN server nearest a user. Caching here means the origin is never contacted.
Cache invalidationMaking caches forget. Notoriously hard, which is why changing the URL is preferred.

In two minutes

Test yourself

1. What does Cache-Control: no-cache actually instruct?

  1. Never store a copy
  2. Store it, but revalidate before every use
  3. Store it for 60 seconds
  4. Only browsers may store it
Show the answer

Store it, but revalidate before every use — no-store is the one that forbids storing. no-cache permits storage but requires a freshness check first.

2. When is immutable safe?

  1. On any static asset
  2. Only when the filename contains a content hash
  3. Only on HTML
  4. Only behind a CDN
Show the answer

Only when the filename contains a content hash — Without a hash, a changed file keeps its URL, and caches will not re-fetch it for the whole max-age.

3. Why did this project choose must-revalidate over a one-year max-age?

  1. It is more secure
  2. There is no build step, so filenames never change
  3. Vercel requires it
  4. It is faster
Show the answer

There is no build step, so filenames never change — No content hashing means a long TTL would strand users on a stale file with no recovery path.

4. Which header pair makes revalidation work?

  1. ETag in the response, If-None-Match in the next request
  2. Expires and Age
  3. Vary and Accept
  4. Location and Referer
Show the answer

ETag in the response, If-None-Match in the next request — The server tags a response with an ETag; the browser sends it back as If-None-Match; if it still matches, the server replies 304 with no body. (Last-Modified / If-Modified-Since is the older date-based pair.)

5. /api/me returns the logged-in user's profile with Cache-Control: public, max-age=300, behind a CDN. What's the risk?

  1. None — five minutes is short
  2. Profile updates appear up to 5 minutes late, nothing worse
  3. Browsers refuse to cache `public` API responses
  4. The CDN can store one user's profile and serve it to the next user who requests `/api/me`
Show the answer

The CDN can store one user's profile and serve it to the next user who requests `/api/me` — public lets a shared cache store it, and the URL is the same for every user. This is a real class of data-leak incident. User-specific responses need private (browser only) or no-store. 'Slightly stale' is the comfortable wrong answer.

6. A bank statement PDF will often be downloaded on shared cybercafé computers. Which directive fits?

  1. max-age=0
  2. no-cache
  3. no-store
  4. private, max-age=3600
Show the answer

no-store — Only no-store stops a copy being written to that machine's disk. no-cache and max-age=0 still store it (they just revalidate), and private only keeps it out of shared caches — the café PC's own browser is exactly the cache you're worried about.

There are 8 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →