Cache-Control: no-cache does not mean "do not cache"
published
TL;DR
no-cache means store it, but ask before reusing it. no-store means write nothing down. If you sent no-cache to keep a bank statement out of a shared cache, it is cached. If you sent no-store to make sure users always see the newest deploy, you threw away every 304 you could have had and now serve the whole file every time.
The problem
Two headers, one word apart, opposite jobs:
Cache-Control: no-cache # cache it; revalidate before each reuse
Cache-Control: no-store # do not put it in a cache at all
RFC 9111 §5.2.2.4 defines no-cache as: the response “MUST NOT be used to satisfy any other request without forwarding it for validation and receiving a successful response”. Storing is fine. Reusing without asking is not.
§5.2.2.5 defines no-store: “a cache MUST NOT store any part of either the immediate request or the response”.
So the name is the trap. no-cache reads like “don’t cache” and behaves like “always revalidate”.
Why it happens
The directive names come from HTTP/1.1 in 1997 and describe the reuse rule, not the storage rule. Nothing in the wire format hints at that, and the two failure modes are quiet in opposite directions:
| You sent | You probably meant | What you got |
|---|---|---|
no-cache on a bank statement | never write this down | written to every cache along the path, including shared ones |
no-store on your JS bundle | always get the newest deploy | zero revalidation, full body re-downloaded on every navigation |
no-cache on a hashed asset | freshness | a conditional request per asset per page load, mostly 304s you did not need |
| nothing at all | sensible defaults | heuristic caching — the cache guesses a lifetime from Last-Modified |
That last row is the one people forget: with no Cache-Control and no Expires, a cache is allowed to invent a freshness lifetime. “I didn’t set a caching header” is not the same as “it won’t be cached”.
What to do
Pick by what the response is, not by how fresh it feels:
# Truly secret, never written down anywhere
Cache-Control: no-store
# Personalised but cacheable in the browser only, checked every time
Cache-Control: private, no-cache
# Content-hashed asset: app.4f2a91.js — the URL changes when the bytes change
Cache-Control: public, max-age=31536000, immutable
# HTML that must reflect the latest deploy, but may be served
# from a CDN for a short window
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=3600
Two directives worth knowing beyond the pair:
private(§5.2.2.7) keeps a response out of shared caches — CDNs, proxies — while still allowing the user’s own browser to store it. This is what most “don’t cache my user data” cases actually want, usually combined withno-cache.must-revalidate(§5.2.2.2) says a stale response may not be reused until it has been revalidated, and a disconnected cache must produce an error — typically 504 — rather than serve it anyway.
For no-cache to be cheap, the response needs a validator. Send an ETag, and the revalidation becomes a conditional request:
curl -sI https://example.com/page | rg -i 'etag|cache-control'
# ETag: "a3f-5c9"
curl -sI https://example.com/page -H 'If-None-Match: "a3f-5c9"' | head -1
# HTTP/2 304
A 304 carries headers and no body. Without a validator, every revalidation is a full download and no-cache costs you the entire payload each time.
Caveats
no-storedoes not mean “not on disk anywhere”. It binds caches as defined by the spec. Browser back/forward navigation, a debugging proxy you installed, and the user’s own “save page as” are all outside it. Treat it as a caching directive, not a security boundary — for secrets the answer is not shipping them to a page you did not intend to.- Cookies do not automatically bypass caches. A shared cache can store a response with
Set-Cookieunless you tell it not to;privateis what stops that, not the presence of a cookie. max-age=0andno-cacheare close but not identical.max-age=0makes the response instantly stale and, absentmust-revalidate, a cache may still serve it stale under some conditions.no-cacheforbids reuse without validation, full stop.- CDNs have their own vocabulary.
s-maxagetargets shared caches only, and most CDNs add proprietary headers (Surrogate-Control,CDN-Cache-Control) that override the standard ones. Check what your edge actually did — a response header is a request; the cache’s behaviour is the answer. immutableis a promise you must keep. It tells the browser not to revalidate even on reload, so it belongs only on URLs whose content can never change — which in practice means content-hashed filenames.