OwlTheEngineer
How It Actually WorksEpisode 19 · Backend

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'.

Owl · 8 min read · 0 views
– comments
Ep 19 · Backend
60s VISUAL BREAKDOWN
Episode 19
Watch the fast visual breakdown or discuss on social:

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.

SYSTEM DESIGN CHECK

Test Your Understanding

Quick architectural scenario challenge based on this breakdown.

SCENARIO

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 –