How HTTP caching works, and the line you have to write yourself
The page said welcome back, with somebody else's name on it. Four questions decide whether something in the path may answer instead of your server — and the default, when you say nothing at all, is not 'don't cache'.
The page said welcome back, with somebody else’s name on it. No session was
stolen and no code was wrong: the response was personalised, it carried no
private directive, and something between your server and the reader stored it and
handed it to the next person who asked.
A cache is anything in the path that may answer instead of the origin, and four
questions decide whether it is allowed to. The video walked all four. This is the
written half, and it covers what ninety seconds could not — the defaults that apply
when you say nothing, the header that keeps one URL from having one answer, the
directive that is not no-cache, and the place this topic turns into a security
problem.
Primary text throughout is RFC 9111, HTTP Caching, June 2022, STD 98. If your notes say RFC 7234, they are one revision behind; 9111 obsoletes it.
The correction that matters most
no-cache does not mean “do not cache.” §5.2.2.4: the directive “in its unqualified
form (without an argument), indicates that the response MUST NOT be used to satisfy
any other request without forwarding it for validation and receiving a successful
response.” Store it, yes — just check with me before you use it.
The one that means don’t keep it is no-store, §5.2.2.5: “a cache MUST NOT store
any part of either the immediate request or the response and MUST NOT use the
response to satisfy any other request. This directive applies to both private and
shared caches.”
A team that ships no-cache believing nothing is retained has a copy of every
response sitting in every cache on the path. That is the single most consequential
misreading in the topic, which is why the video spent two scenes on it and this post
opens with it.
What happens when you say nothing at all
This is the section the video had no room for, and it is the configuration most people are actually running.
Saying nothing does not mean “don’t cache.” §4.2.2: “Since origin servers do not
always provide explicit expiration times, a cache MAY assign a heuristic expiration
time when an explicit time is not specified, employing algorithms that use other
field values (such as the Last-Modified time) to estimate a plausible expiration
time. This specification does not provide specific algorithms, but it does impose
worst-case constraints on their results.”
There are two guardrails on that. A cache “MUST NOT use heuristics to determine
freshness when an explicit expiration time is present,” and heuristics “can only be
used on responses without explicit freshness whose status codes are defined as
‘heuristically cacheable’” — that is episode 17’s list, the one with 404 on it.
And the number everybody ends up implementing: “If the response has a Last-Modified
header field, caches are encouraged to use a heuristic expiration value that is no
more than some fraction of the interval since that time. A typical setting of this
fraction might be 10%.”
Work that through. A document last modified thirty days ago, served with no
Cache-Control at all, can reasonably be treated as fresh for three days. Nobody
chose three days. Nobody wrote a header. It falls out of a default in a document
most teams have not read, and it is why “we never set up caching” and “our change
isn’t showing up” are so often the same sentence.
There is a footnote here that saves an afternoon of confusion: RFC 2616 once
forbade heuristic freshness for URLs with a query string. 9111 records that this
“has not been widely implemented,” and concludes: “origin servers are encouraged to
send explicit directives (e.g., Cache-Control: no-cache) if they wish to prevent
caching.” Adding ?v=2 is not a cache control. Say what you mean in a header.
Vary, or one URL with two right answers
The cache key is “the request method and target URI,” and in practice “many HTTP caches in common use today only cache GET responses and therefore only use the URI as the cache key.” Which is fine right up until one URL legitimately has more than one correct representation — compressed and not, English and French, mobile and desktop.
Vary is how the response says which request headers were part of the decision.
§4.1: when a stored response carries Vary, “the cache MUST NOT use that stored
response without revalidation unless all the presented request header fields
nominated by that Vary field value match those fields in the original request.”
Omit it and a cache will hand the gzipped copy to a client that cannot decompress
it, or the French page to an English reader. The spec has seen this go wrong often
enough to legislate for it: “Some resources mistakenly omit the Vary header field
from their default response… with the effect of choosing it for subsequent requests
to that resource even when more preferable responses are available.”
Two practical notes. Vary: Accept-Encoding is almost always correct and almost
always already set by your server. And Vary: Cookie deserves a hard look — it is
technically right for a page that varies by session, and it is a trap, because
cookies are near-unique per user, so the cache key fragments into one entry per
person and your hit rate goes to nothing. A page that varies by cookie is usually a
page that should be private, not a page that should be keyed on cookies.
must-revalidate is not no-cache
no-cache forces validation on every use. must-revalidate is narrower and
stricter in a different direction. §5.2.2.2: “once the response has become stale, a
cache MUST NOT reuse that response to satisfy another request until it has been
successfully validated by the origin.”
The difference is what happens when the origin cannot be reached. A cache is
normally permitted some latitude with a stale response; must-revalidate removes
it: “In all circumstances, a cache MUST NOT ignore the must-revalidate directive; in
particular, if a cache is disconnected, the cache MUST generate an error response
rather than reuse the stale response. The generated status code SHOULD be 504
(Gateway Timeout).”
The spec is unusually opinionated about when to reach for it: “must-revalidate ought to be used by servers if and only if failure to validate a request could cause incorrect operation, such as a silently unexecuted financial transaction.” An error is better than an old answer — but only where an old answer would be genuinely dangerous, because you are choosing to break the page when the origin is down.
It has one more power worth knowing, since it interacts with the rule below:
must-revalidate “permits a shared cache to reuse a response to a request
containing an Authorization header field, subject to the above requirement on
revalidation.”
Surviving a slow origin
Two directives make staleness useful rather than merely tolerated, and both live outside RFC 9111 in RFC 5861:
stale-while-revalidate“indicates that caches MAY serve the response in which it appears after it becomes stale, up to the indicated number of seconds” — serve the old copy now, refresh in the background. The reader waits for nothing.stale-if-error“indicates that when an error is encountered, a cached stale response MAY be used to satisfy the request, regardless of other freshness information” — your origin falls over and the site keeps serving.
Cache-Control: max-age=60, stale-while-revalidate=600, stale-if-error=86400 is a
defensible default for a public page: a minute of true freshness, ten minutes where
nobody waits on a refresh, and a day of surviving an outage. Note that it is the
exact opposite posture to must-revalidate, which is the point — you are declaring
whether stale-but-served or an honest error is the worse outcome for this
particular resource.
Where this becomes a security problem
Two ways, and the second is the one worth internalising.
The rule has a hole you can walk through. §3.5 protects requests carrying
credentials: “A shared cache MUST NOT use a cached response to a request with an
Authorization header field… to satisfy any subsequent request unless the response
contains a Cache-Control field with a response directive… that allows it to be
stored by a shared cache.”
Read what that names: the Authorization header field. Your app almost certainly
authenticates with a cookie. A session cookie gets no such protection, because
the rule does not mention it — so a shared cache has no reason at all to treat a
personalised response as personal unless the response itself says so. That is the
welcome back bug in one sentence, and it is not a cache behaving badly. It is a
cache behaving exactly as specified.
Which means private is a line you have to write yourself. §5.2.2.7: the directive
“indicates that a shared cache MUST NOT store the response (i.e., the response is
intended for a single user). It also indicates that a private cache MAY store the
response.”
And the general shape: unkeyed inputs. Cache poisoning is what happens when
something affects the response but is not part of the cache key — a header your
application reflects into the page, or that your framework uses to build an absolute
URL, while the cache in front keys only on the URI. One request stores a response
shaped by that input, and everyone afterwards gets it. The defence is the same
sentence read from either end: everything that changes the response must be in the
cache key or must not reach the response. If it genuinely varies the answer, name
it in Vary; if it does not, stop reflecting it. This is the point where caching
stops being a performance topic, and it deserves an episode of its own rather than a
paragraph.
The worked example is this site
Not hypothetical here. These pages are rendered on demand behind a CDN with
s-maxage=60, and the middleware is forbidden from reading a cookie or the database
for any path outside the private ones — precisely so a signed-in member’s name
cannot be rendered into a page the CDN then hands to the next reader. The rule is
not “remember to set private.” The rule is that a cacheable path has no access to
the thing that would make it personal, so the mistake is unavailable rather than
merely discouraged.
That is the whole of freshness and validation: arithmetic on a clock, plus a conversation with the origin when the clock runs out. What none of it explains is why the value you changed an hour ago is still being served after you changed it — because nothing here gives a server a way to reach into a cache and take something back. That is the next episode, and it is a shorter answer than you would like.
Test Your Understanding
Quick architectural scenario challenge based on this breakdown.
You are deploying an updated single-page application and want index.html to reflect deployments immediately while caching assets.
Q1.Which Cache-Control directive ensures the browser always checks with the origin server before using a cached copy?
Comments –
Sign in to share your thoughts
Nothing yet. Be the first.