DevKitHub

no-cache does not mean no cache

3 min read

Almost everyone who writes no-cache means no-store. The two do different things, and the difference decides whether a response with personal data in it is written to disk.

Cache-Control is a short header that looks self-explanatory and is routinely wrong. Most of the damage comes from one misreading: no-cache sounds like it prevents caching and does not. It permits the response to be stored and requires that it be revalidated with the origin before each reuse. The directive that actually prevents storage is no-store.

Why the distinction matters

A page showing someone their own bank balance sent with no-cache is a page a browser may write to disk. It will revalidate before showing it again, so the user will not see stale figures — but the bytes are on the machine, in a profile directory, surviving the tab being closed. On a shared computer that is the whole problem you were trying to avoid.

http
Cache-Control: no-cache        ← stored, revalidated before reuse
Cache-Control: no-store        ← never written anywhere

The naming is historical rather than sensible. no-cache was specified to mean "do not serve this from cache without checking", which is a statement about reuse, not about storage. By the time the ambiguity was obvious the header was everywhere.

The second confusion: who each directive is talking to

Some directives address every cache, and some address shared caches specifically — a CDN, a reverse proxy, anything between the origin and the user that serves more than one person. private tells shared caches not to store the response. s-maxage tells them how long to keep it.

Setting both is a contradiction, and nothing will tell you. The CDN obeys private, stores nothing, and the s-maxage you tuned has no effect on anything. The cache-hit ratio stays flat and the header looks correct in review.

  • max-age — how long any cache may reuse it without asking.
  • s-maxage — the same, for shared caches only, and it overrides max-age for them.
  • private — only the browser may store it. Shared caches must not.
  • public — a shared cache may store it even when the request was authenticated.
  • immutable — the body will not change while fresh, so do not revalidate even on a reload.

The reload problem, and what immutable is for

A long max-age does not stop a browser revalidating when someone presses reload. It will send a conditional request, get a 304, and use what it already had — correct, but a round trip per asset on every reload. For files whose URL contains a content hash, that round trip can never change the answer.

http
# A fingerprinted asset: the URL changes when the bytes change
Cache-Control: public, max-age=31536000, immutable

That is what immutable is for, and it is only correct when the URL really does change with the content. On a URL that can serve different bytes later, it is a promise you will break, and the browser will believe you for a year.

Combinations that cancel out

Several pairs are contradictory in ways no cache reports. no-store alongside max-age — no-store wins and the rest is decoration. public alongside private. immutable with no max-age, so there is no freshness window for the promise to apply to. stale-while-revalidate with nothing making the response fresh in the first place.

Cache-Control AnalyzerPaste a header to get one sentence for what a browser will do and one for what a CDN will do, plus any directives that cancel each other out.

The question worth asking of any Cache-Control header is not "which directives are set" but "what will a browser and a CDN each actually do with this". Those two sentences are usually enough to find the bug, and they are what the analyzer leads with.

Tools for this

Next in this path

NextThe cron expression that runs on the wrong days