Cache invalidation: you cannot reach into a cache
You changed the price an hour ago and somebody just paid the old one. HTTP gives a server exactly one way to remove something from a cache, and it is a side effect rather than a command.
You updated the price an hour ago. The origin has the new one. A customer just paid the old one. Nothing is broken, nothing threw, and there is no error anywhere in the logs — which is exactly why this takes a day to find.
Episode 19 ended at who is allowed to keep a copy. This one starts at the consequence, and it is blunter than most people expect: you cannot reach into a cache. HTTP gives a server one mechanism for removing something from a cache, it is a side effect rather than a command, and everything else is time or a different address.
This is the written half of a ninety-second video, and it covers the parts that
didn’t fit: how a real CDN invalidates a group of pages at once, why a purge is a
broadcast that can partly fail, the header everyone confuses with purging, the
stampede that follows a successful invalidation, and why the answer to a cached
404 is not “stop caching errors.”
The one mechanism, and the word that limits it
RFC 9111 §4.4: “Because unsafe request methods… such as PUT, POST, or DELETE have the potential for changing state on the origin server, intervening caches are required to invalidate stored responses to keep their contents up to date.” And the obligation itself: “A cache MUST invalidate the target URI… when it receives a non-error status code in response to an unsafe request method (including methods whose safety is unknown).”
Three details decide whether this ever helps you.
What “invalidate” means. §4.4: “the cache will either remove all stored responses whose target URI matches the given URI or mark them as ‘invalid’ and in need of a mandatory validation before they can be sent in response to a subsequent request.” Remove, or force a check — either is compliant.
What counts as success. §4.4: “A ‘non-error response’ is one with a 2xx
(Successful) or 3xx (Redirection) status code.” A write that returns 500
invalidates nothing, which is correct and occasionally surprising.
And the word the whole episode turns on: intervening. The obligation binds
caches the request actually passes through. A cache that never sees the write has
received no instruction and has nothing to act on. Your PUT went out from an admin
tool, or a background job, or through a different edge in a different region — and
the cache holding the old page was not on that path. There is no message you can
send it afterwards. RFC 9111 defines invalidation as this side effect, expiry
(§4.2), and validation (§4.3), and nothing else. There is no server-initiated purge
anywhere in the document.
So the exits are the two the video gave. Outlive it: a short max-age with an
ETag, so the copy is never trusted for long — cheap, not free, because the origin
now answers a conditional request on every read. Rename it: the cache key is the
method and the URI, so a new address has no stored copy and nothing to invalidate.
That is the entire reason your build output is app.a7f3c91.js, and why that file
can safely be cached for a year.
What a real CDN does: tag the page, purge the tag
Renaming works beautifully for assets and not at all for content. You cannot
fingerprint /products/42 — the address is the product’s identity, and a reader
arriving from a search result has the old URL.
The answer every serious CDN ships is a second index: the origin attaches one or more tags to a response, the CDN remembers which cached objects carry which tags, and you later purge by tag instead of by URL. Change a seller’s name and you purge the tag for that seller; every product page carrying it goes at once, without you enumerating a single URL.
It is not standardised, and the header name is different at every vendor. Fastly
calls it Surrogate-Key: setting it on an origin response “tells Fastly to index
that response against the specified key (or keys),” and those keys “cannot be used
to find and serve the content in a request, but can be used to target the content
for purging” — multiple keys separated by spaces. Cloudflare calls the same idea
Cache-Tag, and associates the tags “with the content being cached” so you can
purge “all content containing that cache-tag.” Both strip the header before the
response reaches the reader.
Two things follow. This is genuinely the best available answer for a content site, and it is the reason “just use a CDN” is not glib advice. And it is vendor surface, not protocol — write it behind a small interface, because the header name, the purge API and the semantics all change if you move.
A purge is a broadcast, and broadcasts partly fail
The mental model that causes outages is that a purge is a transaction. It is not. A CDN is many points of presence in many places, and a purge is a message that has to reach all of them. Most arrive in a moment. Some arrive later. One can fail while the API call that triggered it returns success, because the API acknowledged the request to purge, not its completion everywhere.
The consequence to design around: for a period after a purge, different readers can legitimately see different versions, and the duration is not something your code knows. Anything that depends on a purge having finished — “publish the post, then send the newsletter” — needs an actual check or a delay, not an assumption. Purge then verify, or purge and tolerate the window. The failure mode is quiet in exactly the way the opening paragraph was.
Clear-Site-Data is not a purge
This one costs teams an afternoon, so it is worth stating plainly. Clear-Site-Data
is a response header that tells the browser to throw away data for an origin.
The "cache" type indicates the server “wishes to remove locally cached data
associated with the origin of a particular response’s URL,” which “includes the
network cache, of course, but will also remove data from various other caches which
a user agent implements (prerendered pages, script caches, shader caches, etc.).”
Every word of that is about the user agent. It reaches one browser — the one that received the response — and it has no effect whatsoever on a CDN, a reverse proxy, or any shared cache between you and that reader. It is the right tool for signing someone out and clearing their local state; it is not a purge and cannot be made into one. (It is also, at the time of writing, still a W3C Working Draft, dated 30 November 2017.)
Invalidation succeeded, and now the origin is on fire
Here is the failure that follows a successful purge. A popular object is dropped from cache. The next thousand requests all miss. All thousand go to the origin, for the same thing, at the same moment.
RFC 9111 describes the behaviour that saves you, in §4: “A cache can use a response that is stored or storable to satisfy multiple requests, provided that it is allowed to reuse that response for the requests in question. This enables a cache to ‘collapse requests’ — or combine multiple incoming requests into a single forward request upon a cache miss — thereby reducing load on the origin server and network.”
That is request collapsing, and it is why a competent cache in front of your origin
turns a thousand simultaneous misses into one fetch. Note the spec’s own caveat in
the next sentence: “if the cache cannot use the returned response for some or all of
the collapsed requests, it will need to forward the requests in order to satisfy
them, potentially introducing additional latency.” A response that is private, or
that varies on something the collapsed requests disagree about, cannot be shared
among them — and the protection quietly evaporates for exactly the responses least
able to afford it.
The companion is stale-while-revalidate from episode 19: serve the old copy while
one request refreshes it, and nobody waits on the miss at all. Which is a useful
thing to notice — staleness, deliberately used, is a defence against the stampede
that invalidation causes.
The cached absence, and why the fix is not “stop caching errors”
The nastiest version of all this is caching a nothing. §2: “it is also possible to
store redirects, negative results (e.g., 404 (Not Found)), incomplete results (e.g.,
206 (Partial Content)), and responses to methods other than GET.” And 404 is on
RFC 9110 §15.1’s heuristically cacheable list, so — as episode 19 showed — a 404
sent with no cache directives at all can be assigned a heuristic lifetime and stored
without anyone choosing to store it.
Someone requests /products/43 before it exists. The 404 is stored. You create
the product. The cache keeps saying it is not there.
The reflex fix is to make 404s uncacheable, and it is the wrong one. Negative caching is genuinely valuable: a missing resource that is requested often — a crawler walking dead links, a client polling for something not created yet — is absorbed by the cache instead of reaching your database every time. Removing it hands every scanner on the internet a direct line to your origin.
The real fix is to make the lifetime match how long the absence is expected to last, explicitly rather than heuristically:
- If the thing might exist soon, send a short explicit
max-ageon the404— seconds, not a heuristic three days. - If the absence is permanent, say so with
410 Gone(episode 17), which is a stronger and more honest claim. - And never let a negative answer be stored under heuristic freshness by accident. If you did not write a directive, you did not decide the lifetime.
One more limit, and it is a good one
§4.4 permits a cache to go slightly further than the target URI: “A cache MAY
invalidate other URIs when it receives a non-error status code in response to an
unsafe request method… In particular, the URI(s) in the Location and
Content-Location response header fields (if present) are candidates for
invalidation.”
But there is a hard stop: “a cache MUST NOT trigger an invalidation under these
conditions if the origin… of the URI to be invalidated differs from that of the
target URI… This helps prevent denial-of-service attacks.” Without that rule, a
POST to your own server could name someone else’s URL in a Location header and
evict it from every cache on the path. The spec closed the door.
The one sentence
The cache was never wrong. It did precisely what your headers told it, for precisely as long as they said. Invalidation is not a message you send — it is either a write that happened to travel through, a clock you set in advance, a name you changed, or a vendor feature that lives outside the standard entirely. Decide which of those four you are relying on, on purpose, before the next person pays last week’s price.
Test Your Understanding
Quick architectural scenario challenge based on this breakdown.
Users update their profile settings and immediately refresh their dashboard.
Q1.Under high write throughput, which cache invalidation strategy best prevents stale reads without overwhelming the database?
Comments –
Sign in to share your thoughts
Nothing yet. Be the first.