Stale-While-Revalidate at the CDN Edge Layer

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:

stateDiagram-v2
  [*] --> Fresh: origin fetch cached
  Fresh --> Stale: s-maxage expires
  Stale --> Revalidating: request within SWR window
  Revalidating --> Fresh: background fetch succeeds
  Stale --> Expired: SWR window elapses
  Expired --> Revalidating: next request blocks on origin
  Revalidating --> Expired: background fetch fails

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.

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.

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.