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.

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.

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.

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.

Runtime-constraints checklist

  • Background revalidation always wrapped in
  • refresh
  • stale-if-error
  • Conditional revalidation (If-None-Match

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.