Stale-while-revalidate — and the bug it ships

stale-while-revalidate serves the cached copy instantly and refreshes from origin in the background, removing the user-visible wait at TTL — but it also extends any cached bug for the entire SWR window after you deploy a fix unless you issue an explicit purge.

Previously

Revalidation works but the user still waits one origin RTT on every check — unless we let the edge serve stale immediately and refresh in the background. That directive is stale-while-revalidate.

Scene 06

Stale-while-revalidate — and the bug it ships

  1. Watch
  2. Try it
  3. Predict
  4. Capture
cache cell · /index.htmlstored: v6SWR ONusers who saw the bugserved pre-deploy after deploy0CELL LIFETIMEFRESH0..60sSTALE · SWR60s..660sEVICTED>660st=0t=TTL (60s)t=TTL+SWR (660s)now · t=0.0sRECENT REQUESTSuser-visible responseinstantbackground revalidationasync → originno requests yet
/index.html · max-age=60, stale-while-revalidate=600. Watch the cursor cross the TTL boundary.
in FRESH — every user gets v6 instantly from the edge.
What to watch for

The 'now' cursor sweeps from t=0 across the cell's lifetime. While it's in the green FRESH band, the cell serves v6 instantly. The moment it crosses TTL into the amber STALE·SWR band, the edge KEEPS serving v6 instantly — and a background arrow fires to origin to refresh. When that lands, the cell flips to v7 and subsequent users see the new version. User-visible latency stays ~2 ms across the boundary.

Continue unlocks when the animation finishes.
Implementation

Highlighted lines are the ones running in the diagram right now.

Edge.handleRequest
the three SWR branches: fresh, stale-but-in-SWR, evicted
def handleRequest(url):
cell = cache.lookup(url)
age = now() - cell.storedAt
if age < cell.maxAge:
return cell.body # FRESH hit, ~2 ms
if age < cell.maxAge + cell.swr:
spawn revalidate(url, cell) # async
return cell.body # STALE, served instantly
# past TTL+SWR — blocking origin fetch
fresh = origin.fetch(url)
cache.store(url, fresh)
return fresh.body
Edge.revalidate
background refresh fired by the SWR branch; user already got bytes
def revalidate(url, oldCell):
# runs in the background; user has already been served
fresh = origin.fetch(url) # may return v6 or v7
cache.store(url, {
body: fresh.body,
storedAt: now(),
maxAge: fresh.cacheControl.maxAge,
swr: fresh.cacheControl.swr,
})
# next request lands in the new FRESH window
Operator.purgeUrl
the only way to short-circuit SWR before the window closes
def purgeUrl(url):
# explicit invalidation at the edge
cell = cache.lookup(url)
cache.evict(url) # next request misses, fetches v7
# without this call, the edge keeps serving the cached
# copy until storedAt + maxAge + swr — even after
# origin has been deploying the fix the whole time
Observability.recordServe
counts users served the pre-deploy version after the deploy mark
def recordServe(servedVersion, t):
if deployTimeSec is None:
return # no deploy yet; nothing to count
if t < deployTimeSec:
return # served before the fix shipped
if servedVersion == PRE_DEPLOY_VERSION:
bugCounter += 1 # user saw the bug post-fix

Where this sits in Build a CDN

Scene 06 of 13, in the Freshness act — TTL, revalidation, stale-while-revalidate.. SWR removes the user-visible TTL-boundary wait by serving stale and refreshing in background — and extends any cached bug for the SWR window after deploy.

Up next. Purge is the escape hatch — but purging this URL in one POP doesn't help the other 299 POPs that are still serving v6.

All 13 scenes in Build a CDN · Every curriculum

Built with Arqly
Every scene in Build a CDN builds on the one before it.All 13 Build a CDN scenes