HTTP Caching From the Server Side
The public seat map is identical for every buyer who has not held a seat, and on the night of an on-sale a thousand browsers on the same event page ask for it every 2 seconds. A CDN in front of Stagedoor can answer them without the request reaching api-01 at all, and it will do exactly what the response headers tell it to, which is the point and the danger. The server decides with Cache-Control, separates the private from the public with one directive, and keeps the CDN honest with the ETag of Chapter 2. Redis saved the database; the CDN saves the instance.
The headers are small, and one of them, set wrong on one route, serves one buyer's orders to the next. This topic reads each directive as an instruction to a cache the service does not operate, decides them per route, and writes the decision where the route is. RFC 9111 is the source for every directive here except stale-while-revalidate, which RFC 5861 added and the CDNs honour.
Cache-Control as Instructions
The seat map's response carries Cache-Control: public, max-age=5, stale-while-revalidate=30. Read as instructions to the CDN: any cache may store this, shared or not; it is fresh for 5 seconds, and for 5 seconds every request for it is answered from the edge without asking the service; and for 30 seconds after that, a cache may keep serving the stale copy while it fetches a new one in the background, so that the first buyer after the 5 seconds does not wait for the origin. That last directive is the HTTP form of the early refresh in Topic 50: the copy is renewed before anyone has to miss on it.
HTTP/1.1 200 OK Cache-Control: public, max-age=5, stale-while-revalidate=30 ETag: W/"8812-v2417" Vary: Accept-Encoding Content-Type: application/json
Four headers carry the whole contract. The first says who may store the response and for how long, and what to do in the 30 seconds after it goes stale. The validator is the one Chapter 2 derived from the seat map's version counter, weak because the balancer compresses the body, and it is what lets the CDN ask "still current?" cheaply once the 5 seconds are up. The Vary names the one request header the body depends on, the accepted encodings, so that a client that cannot take compressed bytes never receives them from the cache. The rest of the topic is what each of those lines does when it is wrong.
Two more directives matter at the edge. s-maxage sets a freshness lifetime for shared caches only, overriding max-age there, for the case where the browser may keep the map for 5 seconds but the CDN should keep it for 2. And must-revalidate forbids serving a stale copy at all, even in an outage, which is right for a value where a stale answer is worse than no answer and wrong for the seat map, where it is the whole idea.
private and no-store
A buyer's orders are hers. The response to GET /orders/{public_id} carries Cache-Control: private, max-age=0: her browser may keep it, and no shared cache may store it under any circumstances. The login response, the token refresh, anything that carries a credential in either direction, is no-store: no cache, shared or private, may keep any part of the request or the response, not on disk, not in memory. Those are the two directives that do what people think "do not cache" means, and neither of them is no-cache.
RFC 9111 adds a default that protects the careless and traps the confident. A shared cache must not reuse a response to a request that carried Authorization, unless the response explicitly permits it, and the directives that permit it are public, s-maxage and must-revalidate. So the buyer's orders are safe from the CDN by default, until somebody adds public to the route because the seat map has it and it "made things faster." The middleware from Topic 22 that sets Cache-Control refuses that: a response to a request that carried Authorization or a session cookie, or a response that sets a cookie, cannot be marked public, and the route that tries fails its test rather than its first buyer.
Vary
One URL, different bodies. The organizer's sales export answers JSON or CSV depending on the Accept header, and a CDN that stores the JSON under the URL alone serves it to the next client that asked for CSV. Vary: Accept tells the cache that the stored response is valid only for requests whose Accept matches the one it was stored for, and it keeps one copy per value. The same applies to Accept-Language for the event page's translated titles and to Accept-Encoding for the compressed seat map: any request header the body depends on is named in Vary, or the first variant is served to everyone.
Vary: Authorization is the case that needs care. In principle it makes a per-user response cacheable per user: the cache keeps one copy per token. In practice it makes the response uncacheable at the edge, because a token changes every 15 minutes and a CDN will not keep a copy per token per buyer, and because the default above already forbids storing it without public. Stagedoor does not vary on Authorization; it marks per-user responses private and lets the browser be the only cache. The one place where the buyer's own data touches a public URL, her held seats on the seat map, was split into a second request in Chapter 2 so that the map itself could stay one body for everyone.
Validators and the 304
After 5 seconds the CDN's copy is stale. It sends GET /events/8812/seats with If-None-Match: W/"8812-v2417", and the service compares the validator against the seat map's version counter, one Redis read from Topic 49's neighbourhood, and answers 304 with no body when they match. The CDN marks its copy fresh for another 5 seconds and serves it. When they differ, because a hold committed, the service answers 200 with the new map and the new validator. The service's cost for a revalidation is a version lookup, not a rebuild, and at 1 revalidation per 5 seconds per edge location it is a rounding error next to the 2,600 reads a second that Redis absorbs.
A max-age without a validator is the expensive version of the same thing: the CDN's copy goes stale after 5 seconds and, with no way to ask whether it changed, it refetches the full 60 KB body, which the service builds or reads from Redis and sends, every 5 seconds per edge location, for a map that changed three times a second at peak and not at all for the other 23 hours of the day. The validator is what turns those refetches into 304s, and the seat map's is free because Chapter 2 derived it from a version the service already keeps.
Purging
A hold changes the seat map, and max-age=5 already bounds how long the CDN can show the old one. To make it immediate, Stagedoor purges: after the commit, a call to the CDN's purge API for /events/8812/seats, the same thing Varnish does when it receives a PURGE request for the path. It is the delete of Topic 50, one layer further out, and it follows the same rule. It goes after the commit, and it goes through the outbox, as a row written in the hold's own transaction and sent by the relay of Topic 41, because a purge sent from the request handler is an outbound HTTP call that can fail or time out, and when it does the stale map lives on the edge for the full max-age with no record that anything was missed.
The purge is also what makes the shorter TTL at the edge worth having. The CDN keeps the map for 5 seconds and Redis for 30, and both are deleted within 100 milliseconds of a commit on the ordinary path. The difference between the two floors is who pays when the delete is missed: a stale Redis value costs a buyer a 409 for up to 30 seconds and nothing else; a stale edge copy is visible from a thousand browsers, so its floor is shorter. Both floors exist because the delete is best effort, and both are measured, the edge's by the CDN's own age header on responses it serves.
What Not to Cache at the Edge
Anything per user without a Vary that makes it per user, which in Stagedoor means anything per user at all: those routes are private. Anything that is a write: a POST response is the answer to one request, and a cache that stores it answers the next buyer with the first buyer's order. Anything whose staleness cost is high by Topic 48's classification, which at the edge means the same list as in Redis: orders, holds, tickets, payments. And the cursor-paginated lists of Topic 16, which are cacheable per cursor value in principle, since each cursor names one page, and rarely worth it in practice, because a thousand cursors are a thousand copies with a hit ratio near zero.
The decision is per route and written beside it. Stagedoor's routes declare their policy in one line next to the handler, the middleware turns it into headers, and a route that declares nothing gets no-store. That default is the safe direction: a route that should have been cached is slow until someone notices the graph, and a route that should not have been cached is a data leak until someone notices the ticket. The middleware's refusal to set public on an authenticated response is the second guard, and the test suite of Chapter 12 has a case per route that asserts the header the route promised.
Redis is inside the service's trust boundary. The code invalidates it by key, it holds any shape the code puts there, and every read of it is a request the instance handled. It saves the database.
The CDN is in front of the service, on somebody else's machines. It is invalidated by TTL and by purge, it holds whole HTTP responses keyed by URL and Vary, and a hit on it is a request the instance never saw. It saves the instance.
The seat map uses both, with the CDN's 5 seconds under Redis's 30, because a stale edge copy is seen by a thousand browsers and a stale Redis value costs one buyer a 409. Neither replaces the other: the CDN cannot answer the buyer's own holds, and Redis cannot stop 2,600 requests a second from reaching the loop.
publicon an authenticated response — the CDN stores one buyer's orders, or her held seats, under the URL and serves them to the next buyer who asks; the worst cache bug there is, and the directive overrides the protection the RFC gave the route by default.- No
Varyon a negotiated response — the sales export stored as JSON is served to the client that asked for CSV, and the organizer's spreadsheet import fails on a body that starts with a brace. max-agewithout a validator — the CDN refetches the full 60 KB body every 5 seconds per edge location instead of receiving a 304 that costs one Redis read.- Purging from the request handler — the CDN call times out at the worst moment, the handler has already returned, and the stale map lives on the edge for the full
max-agewith nothing recorded. no-cachemeaning "do not cache" — it means "store it, but revalidate before every use," and the response is on the CDN's disk after all;no-storeis the directive that forbids storing.- A route with no declared policy — cached by whatever the CDN's defaults are, which for a 200 with no
Cache-Controlmay be minutes, on a route nobody classified.
- Mark
publiconly responses that are the same body for everyone,privatefor anything per user, andno-storefor anything that carries a credential. - Name every request header the body depends on in
Vary, and never vary onAuthorization; make the responseprivateinstead. - Send an
ETagon every cacheable response so the edge revalidates with a 304, and addstale-while-revalidateon the hot ones. - Purge through the outbox, after the commit, with
max-ageas the floor for the purge that is missed. - Declare the policy beside every route, default the undeclared to
no-store, and let the middleware refusepublicon any authenticated response.
Knowledge Check
The seat map is served with public, max-age=5, stale-while-revalidate=30. At second 12 a browser asks the edge for it. What happens?
- The edge forwards the request to the origin and makes the browser wait for the fresh body
- The edge has discarded the copy at second 5, so this is an ordinary miss straight to the origin
- The edge answers with the stale copy only if the origin fails to respond within its timeout
- The edge serves the stale copy immediately and then revalidates it with the origin in the background
A new route returning the organizer's report in JSON or CSV by Accept is deployed with public, max-age=60 and no Vary. What does the CDN do?
- Stores one copy per Accept value, because it always keys every response by the request headers
- Stores whichever variant arrived first and serves it to every client for the next 60 seconds
- Refuses to store the response, because a negotiated body cannot be cached without a Vary header
- Forwards every request to the origin, since a report without Vary counts as private
Marek wants the login response never written to any cache's disk. Which directive does that, and why not the other?
- private, because a login response belongs to a single user and no shared cache is allowed to keep it
- no-cache, because it tells every cache that the response must not be kept anywhere
- no-store, because no-cache still lets a cache keep the bytes and only forces a revalidation
- max-age=0, because a response that is stale on arrival is discarded by every cache
Why does the CDN purge for a changed seat map go through the outbox rather than being sent from the hold handler?
- A purge from the handler can fail with no record; an outbox row is retried until it lands
- The CDN accepts purge calls only from the worker's IP address, so the handler cannot send one
- A purge from the handler would break checkout's 800 ms objective, and the outbox is faster
- The outbox is the only way to make sure the purge arrives after the commit rather than before it
You got correct