awesome-everything RU
↑ Back to the climb

Caching

Cache-Control: the header that programs every cache in the chain

Crux no-cache does not disable caching, private can still leak into a CDN, and a missing s-maxage hands your CDN the wrong TTL. The directives tell browser, proxy, and CDN exactly how to behave — and the defaults are traps.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at junior altitude — the surface
◷ 16 min

A bank dashboard ships Cache-Control: no-cache on its account-balance endpoint. The security review nodded it through — “no-cache, nothing is stored.” A week later a shared kiosk shows one customer the previous customer’s balance after a back-button press. The browser had cached the response the whole time: no-cache never meant “don’t cache.” It meant “cache it, but ask before reusing it.” On bfcache and back/forward, that ask never fired. The directive that would have actually prevented storage was no-store, and nobody shipped it.

Two answers to two different questions

Cache-Control is not one switch; it is a set of directives, and the first job is knowing which directive answers which question. The two questions developers constantly conflate are “may a cache store this?” and “may a cache reuse a stored copy without checking?”

no-store answers the first: do not write this to disk or memory anywhere. Use it for genuinely sensitive, per-request data — bank balances, password-reset pages, anything that must never sit in a kiosk’s bfcache.

no-cache answers the second: you may store it, but you must revalidate with the origin (a conditional If-None-Match/If-Modified-Since request) before serving it. If the origin returns 304 Not Modified, the cache serves the stored body for free; if the content changed, it ships the new one. This is not “off” — it is “always check first.” The MDN/RFC name people expected for no-cache is closer to what no-store does, which is exactly why the confusion is endemic. A large share of no-cache responses in real traffic ship without an ETag or Last-Modified, meaning they can’t even revalidate efficiently — a strong signal the author meant no-store.

max-age, s-maxage, and the CDN that ignores you

max-age=<seconds> sets freshness lifetime: how long any cache may serve the copy without revalidating. s-maxage does the same thing but only for shared caches — CDNs and reverse proxies — and it overrides max-age for them. Private (browser) caches ignore s-maxage entirely.

This split is where a senior earns the title. The trap: you set max-age=0 to keep HTML always-fresh in browsers, but forget s-maxage. The CDN, having no s-maxage, falls back to max-age=0 and revalidates on every request — your origin gets hammered and the CDN does nothing. Or the inverse: max-age=31536000 on HTML with no s-maxage, and the CDN happily caches a logged-in user’s personalized page for a year, serving it to everyone. The two numbers are independent levers because the two cache tiers have different jobs: the browser caches for one user, the CDN caches for all of them.

DirectiveWhat it actually doesThe trap
no-storeNo cache may store the response at allThe one you wanted when you reached for no-cache
no-cacheStore it, but revalidate before every reuseDoes NOT prevent storage; useless without an ETag
max-age=NFresh for N seconds in any cacheCDN uses it too unless s-maxage overrides
s-maxage=NFresh for N seconds in shared caches onlyBrowsers ignore it; forgetting it hands the CDN max-age
privateOnly the browser may store; no shared cacheForgetting it lets a CDN cache one user’s page for all
immutableSkip revalidation entirely while freshOnly safe with content-hashed, never-changing URLs

private vs public, and the leak that ends careers

public says any cache may store this, even with an Authorization header present. private says only a single user’s browser may store it — a CDN or shared proxy must not. The failure mode is brutal and silent: an authenticated, personalized response (/account, a signed-in homepage, an API returning the current user) goes out without private, the CDN caches the first user’s copy, and every subsequent visitor is served someone else’s data. This is a textbook cache-poisoning / data-leak incident, and it almost always traces to a default Cache-Control left on a route that became personalized later.

The senior reflex: any response that varies by who is asking is private, no-store or private, max-age=0 — and you assume a CDN sits in front of you whether you can see it or not. Then Vary: Cookie or Vary: Authorization as a belt-and-suspenders signal so a shared cache keys on the credential.

Why this works

Vary is the cache key, not a directive on lifetime. Vary: Accept-Encoding is fine — two variants (gzip, br). But Vary: User-Agent or Vary: Cookie with high-cardinality values fragments the cache so badly that the hit rate collapses to near zero: every distinct value gets its own entry, so nothing is ever reused. Worse, some CDNs (Cloudflare) ignore Vary on most responses, so a Vary you rely on for correctness may not be honored at all — meaning private/no-store must carry the real safety, not Vary.

immutable, content hashes, and the only way to cache for a year

For static assets the goal is the opposite of HTML: cache as hard as physically possible. The standard is Cache-Control: public, max-age=31536000, immutable — one year, the conventional value for immutable assets (the RFC imposes no cap), with immutable telling the browser to skip even the conditional revalidation it would otherwise do on a hard reload. That saves a round trip per asset, which matters on a page with 80 files.

But a one-year cache is a one-year bug if you ever need to change that file. The pattern that makes it safe is content-hashed filenames: app.4f3a9c.js instead of app.js. The hash is derived from the bytes, so a changed file gets a new name and therefore a new URL — the old URL stays immutable and cached forever, the new URL is fetched fresh. This is why bundlers (Vite, webpack, esbuild) emit hashed filenames by default: it is what lets immutable + one-year max-age be both aggressive and correct. The rule that ties it together: hash the assets, never the HTML. HTML is no-cache (or max-age=0, must-revalidate) so it always picks up the new asset URLs; the assets are immutable so they’re never re-fetched.

must-revalidate is the stricter cousin: once stale, a cache must revalidate and may not serve the stale copy even if the origin is unreachable. The opposite stance is stale-while-revalidate and stale-if-error — explicitly opting in to serving stale content. stale-while-revalidate=600 means “serve the stale copy instantly for up to 600s after expiry while you revalidate in the background,” trading a slightly-stale response for zero latency on the user’s request. stale-if-error=86400 means “if the origin is down, serve stale for a day rather than show an error.” A realistic stacked header for a CDN-fronted API: max-age=60, s-maxage=300, stale-while-revalidate=3600 — 60s fresh in browsers, 5 min in the CDN, then up to an hour of instant-but-stale while a background refresh runs.

Pick the best fit

You serve a logged-in user's personalized HTML dashboard through a CDN. Pick the Cache-Control.

Quiz

A dev wants an API response to never be stored anywhere. They ship Cache-Control: no-cache. What actually happens?

Quiz

You set Cache-Control: max-age=0 on your HTML to keep it fresh, but a CDN sits in front. What's the gap?

Order the steps

Order the questions to pick the right Cache-Control for a response:

  1. 1 Is it sensitive / per-request? If yes → no-store and stop.
  2. 2 Does it vary by user (auth/cookie)? If yes → private (keep it off shared caches).
  3. 3 Is it a content-hashed static asset? If yes → public, max-age=31536000, immutable.
  4. 4 Otherwise pick freshness: max-age for browsers, s-maxage for the CDN (often longer).
  5. 5 Want resilience? Add stale-while-revalidate / stale-if-error to serve stale instead of slow/errored.
Recall before you leave
  1. 01
    A teammate sets no-cache on a sensitive endpoint to stop it being stored. Explain why that's wrong and what to use.
  2. 02
    Walk through why content-hashed filenames let you safely cache assets for a year with immutable, and what must NOT be cached that way.
Recap

Cache-Control is a set of directives answering two separate questions — may a cache store this, and may it reuse a stored copy without asking — and conflating them is the source of most production cache incidents. no-store is the only directive that prevents storage; no-cache permits storage and merely forces revalidation, which is why shipping no-cache on sensitive data leaks it into the browser and bfcache. max-age sets freshness for any cache; s-maxage overrides it for shared caches only, so forgetting s-maxage hands your CDN the browser’s TTL and either hammers the origin or caches personalized pages for everyone. private keeps per-user responses off shared caches, and its absence on an authenticated route is the classic CDN data-leak. Vary defines the cache key, not lifetime, and high-cardinality Vary values collapse the hit rate while some CDNs ignore Vary entirely — so safety must live in private/no-store, not Vary. Finally, content-hashed filenames make public, max-age=31536000, immutable both aggressive and correct: hash the assets so a change means a new URL, and never cache the HTML that points at them.

Continue the climb ↑Cache-Control: multiple-choice review
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.