Stale-While-Revalidate at the Edge
This guide is part of Edge Caching & CDN Integration Patterns. It covers the stale-while-revalidate pattern in depth: serving a stale cached response immediately while refreshing it in the background, the directive semantics that govern it, how each provider maps the behavior, and the two failure modes — thundering herd and unbounded staleness — that turn a good cache into an outage.
The problem: freshness versus latency
Every cache forces a choice between two undesirable extremes. A short TTL keeps data fresh but turns most requests into origin fetches, pushing latency and load back onto the system the cache was meant to protect. A long TTL is fast but serves data that may be minutes or hours out of date. For content that changes occasionally and unpredictably — a product page, a pricing table, an article — neither extreme is acceptable. You want the speed of a long TTL and the freshness of a short one.
stale-while-revalidate (SWR) resolves this by separating when content stops being fresh from when it stops being servable. Inside the freshness window the cache serves the response directly. After the freshness window expires, the cache serves the now-stale response immediately and simultaneously triggers a background fetch to refresh it. The user pays zero latency for the staleness; the next user gets the refreshed copy. The origin is hit exactly once per revalidation rather than once per request.
Root cause: why the edge needs an explicit background primitive
On a traditional server, “revalidate in the background” is easy — you spawn a task and the process keeps running. At the edge this is forbidden by the execution model. An edge isolate is allowed to do work only while it is producing a response; once you return the Response, the runtime is free to tear the isolate down, and any promise still pending is abandoned. A naive fetch() fired without await immediately before return will frequently be killed before it completes.
The platform’s answer is ctx.waitUntil(promise). It tells the runtime: “I have returned the response, but keep this isolate alive until this promise settles.” That single primitive is what makes SWR possible at the edge — it is the only sanctioned way to perform work that outlives the response. Everything in this guide is built on it. The same isolate-lifecycle constraint that mandates waitUntil is why edge functions cannot rely on persistent process state between invocations.
Directive semantics: max-age vs stale-while-revalidate vs stale-if-error
Three Cache-Control directives govern the lifecycle, and conflating them is the most common source of SWR bugs.
max-age/s-maxagedefine the freshness window in seconds.max-ageapplies to all caches including the browser;s-maxageoverrides it for shared (CDN) caches only. While fresh, the response is served with no revalidation.stale-while-revalidate=Nextends servability forNseconds past the freshness window. During this window the cache returns the stale response immediately and revalidates in the background. It does not make content fresh; it makes stale content acceptable to serve while you fix that.stale-if-error=Nis the resilience companion: if revalidation (or a direct origin fetch) fails with a network error or5xx, serve the stale copy for up toNseconds rather than propagating the failure. It turns an origin outage into degraded-but-available service.
A complete directive set looks like Cache-Control: public, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400: fresh for one minute, silently revalidated in the background for the next ten, and resilient to origin failure for a day. The arithmetic of choosing these three numbers is the subject of tuning Cache-Control max-age for edge.
Two further points decide whether those numbers behave the way you expect.
The windows are cumulative, not overlapping. stale-while-revalidate=600 does not mean “this response is servable for 600 seconds”. It means “servable for 600 seconds after freshness ends”. With s-maxage=60, stale-while-revalidate=600 the total servable lifetime of an entry is 660 seconds, of which the first 60 are silent and the remaining 600 each trigger a background refresh on the first request that touches them. Reading the pair as a single 600-second budget is how teams end up with a stale window an order of magnitude shorter than they intended.
Age is measured, not assumed. A shared cache computes an entry’s age from the Date and Age headers of the stored response, and every hop on the way adds its own transit time to Age. That matters as soon as you put a cache in front of a cache: an object that arrives at the outer tier already 40 seconds old inherits that age, so a 60-second s-maxage buys it 20 seconds of freshness, not 60. In the imperative implementation below you take over this bookkeeping yourself with an x-cached-at stamp, which is exact within one PoP but says nothing about the others — each holds an independently aged copy of the same URL. A deploy therefore produces a ragged expiry front rather than a synchronized one, which is usually a blessing: revalidation load spreads out instead of arriving as a spike.
Finally, a directive the origin never sends cannot be honored. If a proxy, framework, or security-header middleware rewrites Cache-Control on the way out — a common side effect of a “harden the headers” change — the CDN sees whatever survived, not what your handler wrote. Confirm the header on the wire, from outside your own network, before debugging anything deeper.
Core implementation with the Cache API
The header form delegates everything to the managed CDN. When you need control — a custom cache key, conditional revalidation, explicit stale-if-error, or revalidation logic the platform cannot express — implement SWR imperatively with the Cache API and ctx.waitUntil.
// Edge-safe stale-while-revalidate using the Cache API and waitUntil.
interface SwrConfig {
maxAge: number; // freshness window (seconds)
swr: number; // additional seconds the stale copy is servable
}
async function staleWhileRevalidate(
request: Request,
ctx: { waitUntil(p: Promise<unknown>): void },
fetchOrigin: (r: Request) => Promise<Response>,
config: SwrConfig,
): Promise<Response> {
const cache = caches.default;
const cached = await cache.match(request);
if (cached) {
const age = responseAge(cached);
if (age <= config.maxAge) {
return cached; // fresh: serve directly
}
if (age <= config.maxAge + config.swr) {
// Stale but servable: serve now, refresh in the background.
ctx.waitUntil(refresh(cache, request, fetchOrigin, config));
return cached;
}
// Beyond the SWR window: fall through to a blocking fetch.
}
const fresh = await fetchOrigin(request);
ctx.waitUntil(cache.put(request, stamped(fresh.clone(), config)));
return fresh;
}
// Compute age from a Date header stamped at write time.
function responseAge(response: Response): number {
const stored = response.headers.get("x-cached-at");
if (!stored) return Infinity;
return (Date.now() - Number(stored)) / 1000;
}
// Stamp the write time and the cache directives onto the stored response.
function stamped(response: Response, config: SwrConfig): Response {
const headers = new Headers(response.headers);
headers.set("x-cached-at", String(Date.now()));
headers.set(
"Cache-Control",
`public, max-age=${config.maxAge}, stale-while-revalidate=${config.swr}`,
);
return new Response(response.body, { status: response.status, headers });
}
async function refresh(
cache: Cache,
request: Request,
fetchOrigin: (r: Request) => Promise<Response>,
config: SwrConfig,
): Promise<void> {
try {
const fresh = await fetchOrigin(request);
if (fresh.ok) await cache.put(request, stamped(fresh, config));
} catch {
// Swallow: the user already has a valid (stale) response. Emit a metric here.
}
}
Two details are load-bearing. First, the age is computed from an x-cached-at stamp written at put time, because the Cache API does not expose a per-entry age to your code. Second, the refresh promise must catch its own errors: it runs after the response is sent, so an unhandled rejection there is invisible to the user but can crash the isolate or, worse, silently stop refreshing.
Worked example: a 400 ms product feed
Numbers make the trade-off concrete. Take a catalog endpoint that costs the origin 400 ms per response, receives 50 requests per second spread across 40 points of presence, and whose data changes a handful of times an hour.
With no-store, the origin serves all 50 requests per second — 180,000 fetches an hour, each costing 400 ms of origin time, and every user waits the full 400 ms plus network on top.
With s-maxage=60 alone, the origin serves roughly one fetch per PoP per minute: about 2,400 fetches an hour, a 98.7 % reduction. But those fetches are blocking. Around 2,400 unlucky users an hour — whoever happens to make the first request to a PoP after its entry expires — pay 400 ms while everyone else pays 4 ms. On a page that fans out to six endpoints tuned this way, the chance that a given page view contains at least one blocking revalidation stops being negligible.
Add stale-while-revalidate=600 and the origin fetch count barely moves, but nobody blocks. The first request after expiry is answered from the stale copy in 4 ms and starts the refresh inside waitUntil; the copy the user sees is at most one refresh interval past its freshness window. The p99 for the route collapses toward the p99 of an ordinary cache hit, and the tail stops tracking origin latency at all. That is the real prize: SWR does not make the cache more efficient, it makes the worst requests cheap.
Now the failure case. The origin goes down for four minutes during a bad deploy. Without stale-if-error, every entry that expires in those four minutes turns into a blocking fetch that fails, and the edge returns 502 for a route whose content has not changed in an hour. With stale-if-error=86400, the same four minutes are invisible: refreshes fail, the catch in refresh fires, the counter increments, and users keep getting the copy from before the deploy. The incident becomes an alert instead of an outage — which is exactly why the alert on that catch block matters more than it looks.
One caveat on the arithmetic: 2,400 origin fetches an hour assumes traffic is spread evenly across PoPs. Real traffic is skewed, so a handful of busy PoPs revalidate on schedule while quiet ones let entries age past the stale window entirely and fall back to a blocking fetch. Quiet PoPs are where users report “the site is slow for me” on a route whose aggregate metrics look perfect.
Provider mapping
| Capability | Cloudflare Workers | Vercel Edge | Netlify Edge Functions |
|---|---|---|---|
| Background primitive | ctx.waitUntil() |
waitUntil() (from @vercel/functions) |
ctx.waitUntil() |
| Imperative cache | caches.default, caches.open() |
Prefer header layer; Cache API limited | caches (Deno) |
| Header SWR | stale-while-revalidate honored on managed cache |
CDN-Cache-Control / Vercel-CDN-Cache-Control |
Netlify-CDN-Cache-Control |
stale-if-error |
Honored on managed cache | Honored via CDN-Cache-Control | Honored |
| Native framework SWR | — | Next.js ISR / revalidate |
On-demand builders |
| State for dedup | KV, Durable Objects | Vercel KV (Upstash) | Netlify Blobs |
On Vercel, the most common path is not the Cache API at all but the framework-native revalidation surface, where s-maxage/stale-while-revalidate on CDN-Cache-Control drive the global edge cache. The concrete Next.js implementation is in implementing stale-while-revalidate in Next.js. On Cloudflare the imperative Cache API approach above is idiomatic and pairs naturally with KV for cross-PoP coordination.
Control-flow variants
Guard variant — bypass for authenticated traffic. SWR is only safe for shared content. Add an early-return guard that skips the cache entirely for authenticated or mutating requests, so per-user content never lands in a shared entry.
function isCacheable(request: Request): boolean {
if (request.method !== "GET") return false;
if (request.headers.has("Authorization")) return false;
if (request.headers.get("Cache-Control")?.includes("no-store")) return false;
return true;
}
Early-exit variant — conditional revalidation. Before refreshing, issue a conditional request with the stored ETag (If-None-Match). If the origin returns 304, skip the cache.put and just re-stamp the freshness window — you avoid re-downloading an unchanged body.
Fallback variant — stale-if-error. When the blocking fetch past the SWR window fails, return the stale copy if one exists rather than an error, honoring stale-if-error. This is the difference between a brief origin blip being invisible and being a site-wide 502.
Edge cases that break a naive implementation
The skeleton above is correct on the common path. These are the cases that quietly make it wrong in production.
- A body can only be read once.
cache.putconsumes the response body. If you store the sameResponseobject you return to the user, one of the two ends up empty. Clone before you store — and clone before you start streaming, not after, because a clone taken from a partially read stream forces the runtime to buffer the remainder in memory. Set-Cookiepoisons a shared entry. Most managed edge caches refuse to store a response carryingSet-Cookie, and any layer that does store it will hand one user’s cookie to the next. Strip it beforecache.put, or treat its presence as evidence that the response was personalized and should not be cached at all.- Partial and redirect responses. A
206 Partial Contentis not a substitute for the whole object, and a302cached with a ten-minute stale window will outlive the condition that produced it. Restrict the SWR path to200— plus, deliberately and with short windows,301and404— and let everything else pass through untouched. Varyinteracts badly with old stale copies. The entry you are about to serve stale was negotiated under whateverVaryaxes existed when it was written. Add an axis later — sayAccept-Language— and entries keyed without it stay servable, returning the wrong variant until they age out. Change the cache key, not just theVaryheader, when the negotiation changes; see cache key normalization and Vary.- Error pages served with a
200. Some origins render “temporarily unavailable” as a200with an HTML body.fresh.okis then true, so you cache the error and serve it for the entire window whilestale-if-errornever fires, because nothing looked like a failure. Validate cheaply beforecache.put: a content-length floor, an expected field, a build-version header. - Deploy-time mass expiry. Purging everything on deploy converts a warm cache into a synchronized miss storm — precisely the thundering herd SWR was meant to smooth out. Purge by tag where you can, and otherwise let the stale window absorb the transition rather than blowing it away.
- Clock skew on the stamp.
Date.now()in one isolate is being compared againstDate.now()in another. The drift is small but not zero, so never build logic that depends on sub-second age precision — round to whole seconds and keep windows comfortably larger than the noise.
Framework integration
Next.js App Router. Route Handlers running on the Edge Runtime emit CDN-Cache-Control directives, or you use export const revalidate and fetch(url, { next: { revalidate: N } }). Constrain the handler with export const runtime = "edge" and a matcher. The full walkthrough is in implementing stale-while-revalidate in Next.js.
Remix. Set headers from a route’s headers export: return { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=600" }. Remix loaders run per request, so the CDN, not the loader, performs the background revalidation.
SvelteKit. Use setHeaders({ "cache-control": "public, s-maxage=60, stale-while-revalidate=600" }) inside a load function, or set the same directive in hooks.server.ts for cross-route policy. Avoid setting SWR on pages that read cookies, or you risk caching personalized output.
Debugging workflow
- Local: run
wrangler dev/next dev/netlify devand confirm the first request is aMISSand the second aHITby inspectingcf-cache-status/x-vercel-cache/Cache-Status. Local emulation of background tasks is imperfect — verifywaitUntilcompletion with a log line insiderefresh. - Tracing: in staging, request a key just past
max-ageand confirm you get the stale body instantly and that a subsequent request returns the refreshed body. Tie both observations into the sametraceparentspan as described in the middleware observability patterns. - Alerting: emit a counter every time
refreshenters itscatchblock. A rising background-revalidation error rate is the earliest signal of an origin problem, and it is invisible to ordinary request success metrics.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Background refresh never runs | fetch fired without ctx.waitUntil |
Always wrap revalidation in ctx.waitUntil |
| Origin melts on cache expiry | Thundering herd — every PoP revalidates the same key at once | Add a short dedup lock in KV or coalesce via a Durable Object |
| Data hours out of date | Unbounded staleness — stale-if-error keeps serving a dead origin forever |
Cap stale-if-error; alert on sustained revalidation failures |
| Personalized content leaks | Authenticated response cached in a shared entry | Guard with isCacheable; never SWR on Authorization/cookie routes |
cf-cache-status always EXPIRED |
Age stamp missing or max-age of 0 |
Stamp x-cached-at at put; set a non-zero max-age |
The thundering herd deserves emphasis. Because the Cache API is per-PoP, an expired key can trigger one revalidation per PoP — potentially hundreds of simultaneous origin fetches for the same object. Mitigate it with a short-lived dedup lock in a KV namespace or a Durable Object, or by enabling a tiered cache that coalesces upstream misses to a single origin fetch.
Runtime-constraints checklist
Frequently Asked Questions
What is the difference between stale-while-revalidate and stale-if-error?
stale-while-revalidate governs normal expiry: after the freshness window, the cache serves stale content while refreshing in the background. stale-if-error is a resilience fallback: it serves stale content when revalidation fails with a network error or 5xx, instead of propagating the failure. You typically set both — the first for latency, the second for availability.
Why must background revalidation use ctx.waitUntil?
Edge isolates are torn down once the response is returned, abandoning any pending promise. A bare fetch fired before return is frequently killed before it completes. ctx.waitUntil(promise) tells the runtime to keep the isolate alive until the promise settles, which is the only sanctioned way to do work that outlives the response.
How do I prevent a thundering herd on cache expiry?
Because the Cache API is per-PoP, an expired key can trigger one revalidation per PoP. Coalesce them with a short-lived dedup lock in a KV namespace or a Durable Object so only one revalidation runs per key, or enable a tiered cache so upstream misses collapse to a single origin fetch.
Can I use stale-while-revalidate for authenticated pages?
No. SWR caches in a shared entry, so personalized or authenticated content would leak between users. Guard the cache path to skip any request carrying an Authorization header or a session cookie, and serve those requests directly from origin or from a per-user store.
How do I bound staleness so content does not drift forever?
Cap the stale-if-error window and alert on sustained background-revalidation failures. Without a cap, a persistently failing origin keeps the stale-if-error window open indefinitely and users see ever-older data. A bounded window plus an alert turns silent drift into an actionable incident.
Does stale-while-revalidate=600 mean the response is servable for 600 seconds in total?
No — the windows are cumulative. stale-while-revalidate=600 extends servability by 600 seconds after the freshness window ends, so s-maxage=60, stale-while-revalidate=600 gives an entry 660 seconds of total servable life: 60 silent, then 600 during which each first touch triggers a background refresh. Reading the two numbers as one budget is a common way to end up with a much shorter stale window than intended.
What happens if a second request arrives while the background refresh is still running?
It also finds a stale entry and, in a naive implementation, fires its own refresh. Within a single isolate you can keep a map of in-flight refreshes keyed by cache key and reuse the pending promise; across isolates and PoPs you need a short-lived lock in KV or a Durable Object. Neither has to be perfect — collapsing the duplicates from hundreds to a few is the entire benefit.
Should I apply a stale window to 404 and redirect responses?
You can, but deliberately and with much shorter windows than a 200. A cached 404 served stale after the resource is created makes a new page look broken, and a 302 outliving the condition that produced it sends users somewhere wrong. Restrict the default SWR path to 200, and opt other statuses in one at a time with windows measured in seconds.