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:
s-maxage— TTL for shared caches (CDN POPs, reverse proxies). After it expires, the edge marks the asset stale.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:
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.
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-maxageto content update frequency — high-churn news sites need shorter TTLs. - Return accurate
Last-ModifiedandETagso crawlers validate without a full download. - Watch
Agein server logs; if crawlers consistently seeAgeabove yours-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
Varyblindly. The worker above deletes the origin’sVaryand setsAccept-Encoding. If a route legitimately varies by a header, such as a locale header on an API, that deletion merges variants. RewriteVaryper 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-errorso 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
?previewparameter 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-revalidateandstale-if-errorper route type, not globally. - Keep
max-age=0on 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
Ageheader 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.