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.

Stale-while-revalidate request timeline Within max-age the cache serves fresh. After max-age but within the SWR window it serves stale and revalidates in the background via waitUntil. After both windows it blocks on origin. max-age stale-while-revalidate expired Serve fresh no origin call Serve stale now revalidate in background Block on origin or stale-if-error ctx.waitUntil(refresh) cache.put after response sent
Within max-age the response is fresh; in the SWR window the stale copy is served instantly while waitUntil refreshes it; past both windows the request blocks on origin or falls back to stale-if-error.

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-maxage define the freshness window in seconds. max-age applies to all caches including the browser; s-maxage overrides it for shared (CDN) caches only. While fresh, the response is served with no revalidation.
  • stale-while-revalidate=N extends servability for N seconds 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=N is the resilience companion: if revalidation (or a direct origin fetch) fails with a network error or 5xx, serve the stale copy for up to N seconds 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.

Directive responsibility matrix Rows for max-age, stale-while-revalidate and stale-if-error are crossed with three request states. Each cell shows whether that directive is active or dormant when a request arrives in that state. Directive Entry within max-age Entry past max-age Refresh fails s-maxage / max-age Serves from cache no origin call Window closed entry is now stale No role stale-while-revalidate Dormant Serves stale now refresh in background No role stale-if-error Dormant Dormant Serves stale copy instead of a 5xx public, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400
Only one directive is ever in charge of a given request, which is why a missing stale-if-error is invisible until the day the origin fails.

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.

Stale hit and background refresh sequence The isolate matches the cache, finds a stale entry and returns it to the client in a few milliseconds. Only after the response is returned does the waitUntil work fetch the origin and write the refreshed copy back to the cache. Client Edge isolate caches.default Origin GET /products cache.match(request) hit, x-cached-at age 92 s 200 stale body in 4 ms response returned — isolate stays alive only because of ctx.waitUntil background fetch cache.put(stamped)
Everything below the dashed line happens after the user already has their bytes, which is why an unhandled rejection down there never shows up in request-success metrics.

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.put consumes the response body. If you store the same Response object 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-Cookie poisons a shared entry. Most managed edge caches refuse to store a response carrying Set-Cookie, and any layer that does store it will hand one user’s cookie to the next. Strip it before cache.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 Content is not a substitute for the whole object, and a 302 cached with a ten-minute stale window will outlive the condition that produced it. Restrict the SWR path to 200 — plus, deliberately and with short windows, 301 and 404 — and let everything else pass through untouched.
  • Vary interacts badly with old stale copies. The entry you are about to serve stale was negotiated under whatever Vary axes existed when it was written. Add an axis later — say Accept-Language — and entries keyed without it stay servable, returning the wrong variant until they age out. Change the cache key, not just the Vary header, when the negotiation changes; see cache key normalization and Vary.
  • Error pages served with a 200. Some origins render “temporarily unavailable” as a 200 with an HTML body. fresh.ok is then true, so you cache the error and serve it for the entire window while stale-if-error never fires, because nothing looked like a failure. Validate cheaply before cache.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 against Date.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

  1. Local: run wrangler dev / next dev / netlify dev and confirm the first request is a MISS and the second a HIT by inspecting cf-cache-status / x-vercel-cache / Cache-Status. Local emulation of background tasks is imperfect — verify waitUntil completion with a log line inside refresh.
  2. Tracing: in staging, request a key just past max-age and confirm you get the stale body instantly and that a subsequent request returns the refreshed body. Tie both observations into the same traceparent span as described in the middleware observability patterns.
  3. Alerting: emit a counter every time refresh enters its catch block. 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.

Per-PoP revalidation before and after a dedup lock Without coordination every point of presence revalidates the same expired key independently and the origin receives one fetch per PoP. With a short-lived shared lock only the first PoP fetches while the others keep serving their stale copy. Without a dedup lock With a short-lived dedup lock PoP IAD PoP LHR PoP NRT PoP GRU Origin 4 identical fetches Each PoP holds its own entry and revalidates the moment it expires PoP IAD PoP LHR PoP NRT PoP GRU KV lock short TTL Origin 1 fetch The first PoP wins the lock and refetches; the rest keep serving their stale copy
The lock does not have to be correct in the distributed-consensus sense — a short TTL that collapses four hundred fetches into a handful is already the whole win.

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.