Stale-While-Revalidate at the CDN Edge Layer

Part of Content Delivery Network Routing Logic, stale-while-revalidate at the edge serves a cached payload instantly while an asynchronous background fetch refreshes it, decoupling user-facing latency from origin response time. For a headless CMS this cuts Time to First Byte and shields the origin from traffic spikes — but only with precise header composition, explicit routing boundaries, and disciplined invalidation. A misconfigured directive or an overlapping route breaks the contract, serving stale drafts or stampeding the origin.

The two directives that govern SWR

The behavior lives entirely in the Cache-Control response header, via two directives:

  1. s-maxage — TTL for shared caches (CDN POPs, reverse proxies). After it expires, the edge marks the asset stale.
  2. stale-while-revalidate — grace period during which the edge keeps serving the stale payload while fetching a fresh copy.

Per RFC 5861, these are independent. A production header like public, s-maxage=300, stale-while-revalidate=86400 serves fresh content for five minutes, then stale content for up to 24 hours while refreshing in the background.

The cached entry moves through these states as the two windows elapse:

Cache entry states under s-maxage and stale-while-revalidateAn entry is fresh until s-maxage expires, then stale; a request inside the stale window triggers background revalidation and is served the stale copy; after the window the entry expires and the next request blocks on the origin.FreshStaleRevalidatingserve staleExpiredRequest blockson origins-maxagerequestsuccesswindow endsrequestresponse
Readers only wait in the expired state; the stale window exists so that almost nobody reaches it.

Routing matters as much as the header. Draft, preview, and authenticated endpoints must bypass the shared cache; see Content Delivery Network Routing Logic. Without path- or header-based isolation, an edge worker can cache a preview payload and serve a stale draft to an editor.

Edge worker implementation

Many CMS platforms omit SWR directives or apply blanket caching that conflicts with Jamstack routing. Cloudflare Workers, Vercel Edge Functions, and Fastly Compute@Edge need explicit header injection: SWR for public routes, full bypass for preview.

TypeScript
import type { ExecutionContext } from '@cloudflare/workers-types';

interface Env {
  // Environment bindings if needed
}

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext
  ): Promise<Response> {
    const url = new URL(request.url);

    // Identify preview/draft traffic via query params or custom headers
    const isPreview =
      url.searchParams.has('preview') ||
      request.headers.get('x-cms-mode') === 'draft' ||
      request.headers.get('cookie')?.includes('cms_preview=1');

    // Editorial traffic bypasses the shared cache
    if (isPreview) {
      return fetch(request, {
        cf: { cacheTtl: 0, cacheEverything: false }
      });
    }

    const originResponse = await fetch(request);

    // Clone headers — origin headers are immutable
    const modifiedHeaders = new Headers(originResponse.headers);

    // Apply SWR only to successful, cacheable JSON
    if (originResponse.ok && originResponse.headers.get('content-type')?.includes('application/json')) {
      modifiedHeaders.set('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=86400');

      // Strip headers that would fragment the cache key
      modifiedHeaders.delete('set-cookie');
      modifiedHeaders.delete('vary');
      modifiedHeaders.set('Vary', 'Accept-Encoding');
    }

    return new Response(originResponse.body, {
      status: originResponse.status,
      statusText: originResponse.statusText,
      headers: modifiedHeaders
    });
  }
};

The worker intercepts the request, forces a cache bypass on preview flags, proxies public routes to the CMS, then rewrites Cache-Control to enforce SWR regardless of origin config and sanitizes Vary/Set-Cookie to stop cache-key multiplication. Fastly VCL or Compute@Edge needs equivalent logic in vcl_recv and vcl_deliver; set beresp.stale_while_revalidate explicitly when origin headers are unreliable.

Stampedes and cache-key fragmentation

The most common cause of degraded edge performance is the cache stampede: concurrent requests during the revalidation window each trigger a background fetch, negating SWR’s origin shielding. Enable request coalescing in your CDN control plane — Cloudflare, Fastly, and CloudFront all serialize identical origin requests into a single fetch and queue the rest.

Then audit Vary for fragmentation. Vary: Cookie or Vary: Authorization multiplies cache keys per unique token, defeating SWR. Restrict Vary to low-cardinality headers like Accept-Encoding or Accept-Language. For client-side hydration and polling that complements the edge, see Data Fetching & Caching Strategies.

Origin requests during a traffic spike on one stale pageRequests reaching the origin in the first second after a popular page goes stale, with 500 concurrent readers, with and without request coalescing at the edge.No coalescing, 20 POPs500 requestsCoalescing per POP20 requestsCoalescing + origin shield1 requests
Coalescing turns a stampede of identical revalidations into one origin request per POP.

Webhook invalidation

Webhook-driven purges race during high-frequency publishing. A purge can miss if the request fails, routes to a secondary POP, or lands after the SWR grace period started. Use idempotent retries with exponential backoff, and cross-reference CDN purge logs against CMS deploy timestamps. If the edge returns X-Cache: HIT indefinitely after a publish, check that your response transformer isn’t stripping stale-while-revalidate or injecting a conflicting no-cache.

Soft purge with stale-while-revalidateA publish triggers a soft purge that marks the object stale rather than deleting it; the next reader gets the stale copy instantly while the edge fetches the new version in the background.CMS publishPurge APIEdge POPReaderOriginsoft purge tagmark staleGET pagestale copy, instantbackground fetchfresh copy
A soft purge keeps the no-waiting guarantee of stale-while-revalidate even immediately after a publish.

For GraphQL backends, layer Apollo Client GraphQL Caching or React Query for CMS Data at the client — the edge handles network latency, the client handles UI revalidation.

Crawlers and SEO

Crawlers often request pages mid-revalidation. Serving stale content to Googlebot during the stale-while-revalidate window is fine as long as the content stays semantically valid and the window is reasonable (typically under 24 hours for marketing pages). To stay crawl-efficient:

  • Match s-maxage to content update frequency — high-churn news sites need shorter TTLs.
  • Return accurate Last-Modified and ETag so crawlers validate without a full download.
  • Watch Age in server logs; if crawlers consistently see Age above your s-maxage, the grace period is too long for SEO-sensitive routes.

Strict Cache-Control composition, preview isolation, request coalescing, and synchronized webhook purges are what turn a latency-bound CMS into a low-TTFB delivery layer that holds up across Jamstack and Next.js ISR deployments.

Configuration Reference

Directive Typical value Meaning
s-maxage 300 s Freshness lifetime in shared caches only.
stale-while-revalidate 86400 s How long a stale copy may be served while refetching.
stale-if-error 604800 s How long a stale copy may be served when the origin fails.
max-age 0 for HTML Browsers always revalidate, so publishes reach them after purges.
Vary Accept-Encoding Low-cardinality only; never cookies or tokens.
Request coalescing on One revalidation per key per POP.

Some CDNs ignore stale-while-revalidate in Cache-Control and need their own configuration, for example a separate setting, a Surrogate-Control header or a VCL variable. Check each provider’s documentation and verify behaviour with the Age header: during the stale window, responses should arrive instantly with an Age larger than s-maxage.

Gotchas & Edge Cases

  • Stripping Vary blindly. The worker above deletes the origin’s Vary and sets Accept-Encoding. If a route legitimately varies by a header, such as a locale header on an API, that deletion merges variants. Rewrite Vary per route, not globally.
  • Revalidating with a failing origin. A background fetch that fails leaves the stale copy in place until the window ends, then readers block. Add stale-if-error so failures extend the stale copy’s life instead.
  • Very long stale windows on legal content. A 24-hour stale window means a corrected price or legal statement could be served for a day if purges fail. Use shorter windows for content where staleness has consequences.
  • Preview detection by query string only. A ?preview parameter can be dropped by link sharing tools or normalized away by the CDN. Detect preview by cookie or header as well, as the worker does.
  • Hard purges erase the safety net. Deleting objects on publish makes the next reader wait for the origin. Prefer soft purges where available.

Worked Example

A documentation site on Sanity and Cloudflare served every page with s-maxage=60 and no stale directives. Each minute, every popular page expired and the next reader in each POP waited for a full render, which showed up as a sawtooth in TTFB. Switching to s-maxage=300, stale-while-revalidate=86400, stale-if-error=604800 with tag purges on publish removed the sawtooth: readers were served from cache, publishes still appeared within seconds through purges, and a two-hour Sanity API incident a month later passed without readers noticing.

Rollout Checklist

  • Set s-maxage, stale-while-revalidate and stale-if-error per route type, not globally.
  • Keep max-age=0 on HTML so browsers always check with the edge.
  • Detect preview by cookie and header, and bypass the cache for it.
  • Enable request coalescing and, for large sites, an origin shield.
  • Switch purges to soft purges where the CDN supports them.
  • Watch the Age header and origin request rate after rollout to confirm the stale window is working.

Whichever layer you change first, measure before and after with the same instruments: the CDN’s cache-status and Age headers for correctness, real-user TTFB per region for impact, and origin request rate for cost. A routing or caching change that improves one of those at the expense of another is usually a keying mistake, and the three together make it visible within a day of rollout.

Frequently Asked Questions

Is edge stale-while-revalidate the same as Next.js ISR?

They follow the same serve-stale-then-refresh idea at different layers. ISR regenerates the page in the application; edge stale-while-revalidate refetches it from the application into the CDN. Both are usually active, and purges must respect their order: regenerate, then purge.

How long should the stale window be?

As long as your content can safely be out of date when purges fail, because under normal operation purges keep it fresh. A day suits most editorial content; minutes suit prices and inventory.

Do browsers honour stale-while-revalidate too?

Some do for max-age based caching, but HTML is usually sent with max-age=0 so browsers revalidate with the edge on every navigation. Keep stale behaviour at the edge, where purges can reach it.

Why do some readers still wait after enabling stale-while-revalidate?

Because a page nobody requested during the whole stale window expires, and the next reader then blocks on the origin. Rarely visited pages hit this often. A longer stale window, cache warming for important pages, or stale-if-error for outages reduce it; for the true long tail, an occasional blocking request is an acceptable cost.