Two days at a stop that had moved
Road works moved stop 4417 of a city's tram network one block north. The transit agency updated its stop list that morning, and for two days returning riders of its departures site kept seeing the old location. The stop list, stops.json, went out with a Last-Modified date twenty days old and no Cache-Control header, and with no instruction from the origin an HTTP cache may guess how long a response stays usable. RFC 9111 suggests a fraction of the time since the last change, typically 10%: two days. That guess is heuristic freshness, and both the riders' browsers and the CDN in front of the agency were entitled to it.
Every cache on the path follows one protocol, and the origin's headers are its instructions: how long a copy may be reused, what to do after that, and who may keep one. A private cache serves one user, as a browser does; a shared cache, a CDN or a proxy, serves many. Which layers to cache in is Caching layers; this article is the protocol they all speak.
Fresh for ten seconds, counted from the origin
The fix starts by saying it. Cache-Control: max-age=300 gives the stop list a freshness lifetime of 300 seconds. A cache compares it with the response's age, the time since the origin produced or last confirmed it: while the age is below the lifetime, the stored response is fresh and the cache answers with it without asking anyone. After that it is stale.
Age is counted across caches. Each stop's departures, /stops/4417/departures, are rebuilt from vehicle positions every few seconds and go out with max-age=10. When the CDN edge answers from a copy it fetched seven seconds earlier, it adds Age: 7, and the browser counts those seven seconds, leaving three seconds of freshness rather than ten. Give or take network delay, ten seconds bounds how long ago the origin produced or confirmed the list a rider sees.
The edge answers while its copy is fresh and goes back to the departures service once it is stale. The browser counts the Age the edge reports.
Asking whether it changed
Going stale does not delete a copy. The stop list is 300 KB and changes a few times a month, so a copy that has just gone stale usually still matches the origin's. The agency adds a validator, a value that identifies this version: ETag: "stops-812", an entity tag that changes whenever the list does. Now a stale copy can be checked instead of downloaded again. That check is revalidation, made with a conditional request:
- The browser finds its copy of
stops.jsonstale and sendsGET /stops.jsonwithIf-None-Match: "stops-812". - The CDN edge finds its own copy stale too and sends the stops service a conditional request with the same tag.
- The stops service sees that the current list still has that tag and answers
304 Not Modified: headers, including itsCache-Control, and no body. - The edge updates its stored copy with those headers, which makes it fresh for another 300 seconds, and answers the browser with a
304of its own. - The browser updates its copy the same way and shows the list it already had.
A few hundred bytes crossed the network instead of 300 KB; had the tag not matched, the answer would have been a 200 with the new list. A 304 still costs the round trip, so a long max-age beats revalidation wherever one is safe. The older validator, Last-Modified, works the same way through If-Modified-Since, to the second.
The tag must also be the same on every server: the agency's three servers first derived it from file metadata on their own disks, as some web servers do by default, and most conditional requests missed until the tag was computed from the content.
The account page that went to the wrong rider
The account page, with a rider's travel-card balance and trips, first went out with no Cache-Control, behind a CDN rule that cached every page for a minute, and its responses set the session cookie. HTTP does not forbid a shared cache from storing a response with Set-Cookie, so for that minute other riders received the first rider's page and cookie. Many CDNs skip such responses by default; the cache-everything rule had overridden that. The origin has to say who may keep a copy:
no-store: no cache may keep any part of the response. The account page now goes out this way, so caches that follow the protocol keep no copy.private: the rider's browser may store the response, the CDN must not. It is a caching instruction, not access control.no-cache: a cache may keep the response but must revalidate it before every use. It does not mean "do not cache", and it binds shared caches as well as browsers.s-maxage: a lifetime for shared caches only, used in place ofmax-age; it also forbids them to serve the copy stale without revalidating.
A response to a request with an Authorization header is protected by default: a shared cache must not reuse it for a later request unless the origin allows that with public, s-maxage or must-revalidate, so public stays off personal responses. must-revalidate forbids serving a stale copy without a successful revalidation, so a cache cut off from the origin returns an error, usually 504, instead of the copy it could otherwise serve. immutable (RFC 8246), on a hashed bundle with a year's max-age, tells a browser that honours it not to revalidate while the copy is fresh, even on a normal reload.
No header recalls a copy a browser already holds, so a changed file gets a new name. An edge can be emptied early by a purge, and CDNs also take their own TTL settings and edge-only headers such as CDN-Cache-Control; CDN & static delivery covers that side.
One URL, two languages
The departures site answers in French or Dutch, chosen from the browser's Accept-Language header, and a week after launch French-speaking riders start getting Dutch. A cache files each response under a cache key, by default the method and the URL, and /stops/4417/departures was one URL for both languages: whichever reached the edge first served everyone until it went stale. In practice most caches store only responses to GET, one reason a GraphQL API that sends every query as POST gets little from them (API styles: REST vs GraphQL vs gRPC).
Vary: Accept-Language tells caches that this request header took part in choosing the response, so a stored copy is reused only for a request whose Accept-Language matches. Some CDNs act on Vary only once their own cache settings include that header, so check the edge too. It costs hit rate, because browsers spell one preference many ways and each spelling can get its own copy; normalising the header at the edge, or putting the language in the URL, keeps one copy per language. Vary: Cookie goes further: with a per-user session cookie, riders do not share a copy, so the hit rate falls toward one rider's own repeats; Vary: User-Agent splits it across every browser version.
Stale on purpose
With ten seconds of freshness, the request that finds the edge's copy stale waits while the edge goes back to the service. stale-while-revalidate=20 (RFC 5861) lets a cache answer with the stale copy for up to 20 more seconds while it revalidates in the background. A departure list can now be about 30 seconds old when shown, which the agency accepts for predictions that move by the minute.
stale-if-error=600 covers the bad day: when the origin returns a 500, 502, 503 or 504 or cannot be reached, a cache may keep serving the stale copy until it has been stale for ten minutes, and the page shows when the list was computed. Support for both directives varies, broader among CDNs than browsers. Thousands of requests going to the origin at once for one stale copy is a different problem, covered in Hot keys & cache stampedes.
Follow one copy through your edge
In the Content Delivery Network Challenge, which asks the edge to "Honor cache-control and TTL semantics", take the stop list, sent with max-age=300 and an ETag, and follow one copy through your edge node from the first miss until it goes stale and is revalidated. On the line from the edge node to the origin, write what is sent and what comes back each time: the full 200, then the conditional request, then the 304 that makes the copy fresh again.