What you'll learn
- Quick answer
- The Gap Static Caching Leaves Open
- stale-while-revalidate: Serve Old, Fetch New, In The Background
- Walking Through a Real Request Timeline
- Edge Functions: Running Actual Code Close To The User
- Cache Invalidation: Making Old Content Actually Go Away
- Caching Content That's Different Per User
- FAQ
Quick Answer
Modern CDNs don't just cache static files forever — they use directives like stale-while-revalidate to serve a slightly-old response instantly while fetching a fresh copy in the background, and they run actual code at edge locations (Cloudflare Workers and similar platforms) to compute or personalize responses close to the user instead of only relaying pre-baked cached bytes.
The Gap Static Caching Leaves Open
A CDN's classic job — caching an image, a CSS file, or a fully static HTML page at edge locations around the world so a user in Bangalore isn't fetching bytes from a server in Virginia — is well covered ground; if you want the mechanics of edge caching and DNS anycast itself, that's already written up in our earlier post on how CDNs make a site feel faster. This post picks up exactly where that one stops being enough: what happens when the content genuinely isn't static. A product page whose stock count changes hourly, a news homepage that updates every few minutes, a logged-in dashboard that's different for every single user — none of these can just be cached forever with a long max-age, because doing so would show stale or wrong data to real people. But refusing to cache them at all throws away most of a CDN's value and sends every single request all the way back to origin.
Two ideas solve this gap without giving up on caching entirely: stale-while-revalidate, a Cache-Control directive that lets a slightly-old response go out instantly while a fresh one is fetched in the background, and edge functions, actual code running at the CDN's edge locations instead of only relaying pre-baked responses, which lets you compute or personalize a response close to the user instead of routing every dynamic request back to a single origin server.
stale-while-revalidate: Serve Old, Fetch New, In The Background
stale-while-revalidate is a Cache-Control extension defined in RFC 5861 (also documented in MDN's Cache-Control reference), and its exact syntax is a number of seconds attached as a directive alongside a normal max-age:
Cache-Control: max-age=600, stale-while-revalidate=30Here's precisely what that header means, per the RFC's own wording: for the first 600 seconds (max-age), the response is fully fresh and gets served as-is with no extra work. Once it passes 600 seconds and becomes stale, the cache is allowed to keep serving that same stale response for up to another 30 seconds (the stale-while-revalidate window) — but the moment it does that, it's also supposed to kick off a background request to origin to fetch a fresh copy, without making the user's request wait for that fetch to finish. The next user request after the background fetch completes gets the fresh version; nobody in that stale-while-revalidate window was blocked waiting on origin at all.
The important word in the RFC's own description is non-blocking. A plain max-age cache has exactly two states from the requester's point of view: fresh (serve immediately) or expired (block this request while origin is contacted). stale-while-revalidate adds a third state — stale-but-still-usable — that avoids the latency spike an expired cache entry would otherwise cause on the very next request after expiry, which is precisely the request most likely to be a real user rather than a background job.
Walking Through a Real Request Timeline
Take the header above and trace what happens to a page across several requests over time:
- t=0s to t=600s — every request gets the cached response instantly. It's fully fresh; origin isn't contacted at all.
- t=615s — a request arrives after the 600-second
max-agehas expired, but still within the 30-secondstale-while-revalidatewindow. The cache serves the stale response immediately (fast, no waiting) and, in the background, fires off a request to origin for a fresh copy. - t=616s — the background revalidation from the previous request finishes and the cache now holds a fresh copy. Any request from this point forward gets the new content, instantly, no background fetch needed until this cycle repeats.
- t=640s (if, hypothetically, nobody had requested the page between 600s and 630s to trigger a revalidation) — the stale-while-revalidate window has now also expired, so the cache falls back to normal behavior: this request blocks while origin is contacted directly.
The practical effect is that as long as a resource gets requested reasonably often relative to its stale-while-revalidate window, real users almost never experience the latency spike of a cache miss — some request eventually "absorbs" the background revalidation cost on everyone else's behalf, invisibly.
Edge Functions: Running Actual Code Close To The User
stale-while-revalidate helps with content that changes on its own timeline, but it doesn't help with content that's genuinely different per request — a personalized recommendation, an A/B test variant, a request that needs to check a cookie before deciding what to return. For that, modern CDNs let you deploy actual JavaScript (or WASM) that runs at each edge location itself, rather than only caching what a single origin server produced. Cloudflare Workers is one of the best-known examples, and its current fetch handler shape is genuinely simple — this is the real, current API, not an approximation of an older version:
// Cloudflare Workers fetch handler - runs at the edge, close to the user
export default {
async fetch(request, env, ctx) {
const cacheKey = new Request(request.url, request);
const cache = caches.default;
let response = await cache.match(cacheKey);
if (!response) {
// Not cached at this edge location yet - go compute/fetch it
response = await fetch(request); // could also call an API, run logic, etc.
response = new Response(response.body, response);
response.headers.set("Cache-Control", "max-age=600, stale-while-revalidate=30");
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
return response;
},
};What makes this meaningfully different from a traditional serverless function (an AWS Lambda behind API Gateway, for instance) is where it runs and how it starts up. A Worker executes inside a lightweight V8 isolate at whichever of the CDN's edge locations is physically closest to the requester, and isolates start in low single-digit milliseconds rather than the tens-to-hundreds of milliseconds a traditional container-based cold start can take, since there's no container or full JS runtime process to boot — the isolate is just a sandboxed slice of an already-running V8 engine. That combination — genuinely close to the user, and fast enough to start that "cold start" stops being a design constraint most requests need to worry about — is what makes it practical to run real per-request logic (auth checks, header rewriting, A/B bucketing, geolocation-based responses) at the edge instead of forwarding every single dynamic request back to one origin region.
Note what the example code is doing with the Cache API (caches.default) alongside the compute: it's not an either/or between edge functions and caching. A Worker can check its local edge cache first, serve instantly on a hit, and only run heavier logic or contact origin on a miss — combining computed responses with the same underlying stale-while-revalidate-style caching discussed above.
Cache Invalidation: Making Old Content Actually Go Away
Time-based expiry (max-age, stale-while-revalidate) handles content that goes stale predictably. It doesn't handle content that needs to change right now — a product's price just got corrected, an article had a factual error fixed, a user just changed their profile photo. For that, CDNs expose explicit purge mechanisms rather than making you wait out a TTL:
- Purge by URL — the blunt-but-universal option: tell the CDN "this exact URL is now invalid," and the next request for it is treated as a miss and goes to origin.
- Purge by cache tag / surrogate key — many CDNs let you tag a cached response with one or more labels when it's stored (say,
product-4471orcategory-shoes), so a single purge call can invalidate every cached page that happens to include that product, no matter how many different URLs rendered it — critical for something like an e-commerce catalog where one price change can appear on a product page, a category listing, and a search results page simultaneously. - Event-driven purge on publish — rather than relying purely on time, the publishing action itself (saving a CMS article, updating inventory) triggers an explicit purge call as part of that same operation, so the cache is invalidated the instant the underlying data changes instead of up to
max-ageseconds later.
Well-designed systems combine both approaches: a reasonably short TTL as a safety net that guarantees content can't stay wrong forever even if a purge call is missed, plus explicit event-driven purging for the cases where "eventually correct" isn't good enough.
Caching Content That's Different Per User
The last piece is content that's legitimately different for every visitor — a signed-in dashboard, a shopping cart, region-specific pricing. Caching this wrong (serving user A's dashboard to user B) isn't a minor bug, it's a data leak, so it deserves to be treated carefully rather than just "turning caching off" wholesale, which is the common but overly blunt response.
The standard mechanism is the Vary HTTP header, which tells a cache that responses to the same URL should be stored as separate cache entries keyed by the value of one or more other request headers — Vary: Cookie or Vary: Authorization, for example, so a logged-in user's personalized response is never served to someone with a different session cookie. In practice, most systems go further and split the page itself: cache the parts that are genuinely shared across everyone (page layout, navigation, product descriptions) aggressively, and fetch or compute the per-user parts (cart contents, "welcome back" text, personalized recommendations) separately, often via a small edge function call or a client-side fetch after the cached shell loads — which is exactly the kind of split edge functions and stale-while-revalidate together make practical: cache what's shared, compute what's not, and keep both fast.
