~/blog

Cache-Control: no-cache does not mean "do not cache"

published

#http#caching#performance

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 sentYou probably meantWhat you got
no-cache on a bank statementnever write this downwritten to every cache along the path, including shared ones
no-store on your JS bundlealways get the newest deployzero revalidation, full body re-downloaded on every navigation
no-cache on a hashed assetfreshnessa conditional request per asset per page load, mostly 304s you did not need
nothing at allsensible defaultsheuristic 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:

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

References