Stale-While-Revalidate Implementation: A Production Blueprint for Frontend Performance
This guide extends the broader advanced caching and CDN architecture reference with one focused technique. Stale-While-Revalidate (SWR) is the HTTP caching directive that decouples response latency from data freshness: it serves a cached asset instantly while silently fetching a fresh copy in the background. Configured correctly, a stale cache hit returns with TTFB ≤ 10ms, the background fetch never blocks the main thread, and the downstream Largest Contentful Paint holds under LCP < 2.5s while interaction latency stays under INP < 200ms. Configured carelessly, it ships deprecated bundles after a deploy or lets background fetches compete with critical rendering work.
The sections below walk a diagnostic workflow — baseline, isolate, fix, validate — across the origin, the edge, and the service worker. Every code block carries an explicit trade-off comment marking when the pattern is the wrong choice.
Problem Framing: The Freshness-vs-Latency Tension
Every caching decision trades freshness against latency. Strict max-age keeps data current but pays a full origin round trip the moment it expires, spiking TTFB past the 200ms boundary on the unlucky request. Long max-age keeps TTFB low but risks serving stale content. SWR resolves the tension by serving the stale copy and refreshing — but it introduces its own measurable failure modes: a zero-length stale window that silently disables the feature, a Vary header that fragments the cache so the "hit" is actually a miss, and background fetches that contend with main-thread work and inflate INP. Each maps to a number you can watch.
The discipline is to name the degraded metric before changing config. A TTFB regression on an SWR route is a stale-window or fragmentation problem; an INP regression is a fetch-priority or synchronous-cache-write problem. Different causes, different fixes.
Prerequisites: Versions, Directives, and Support
SWR is standardized in RFC 5861 and supported by modern browsers and every major CDN. Confirm the moving parts before implementing:
- Browser HTTP cache honors
Cache-Control: stale-while-revalidatenatively in current Chromium, Firefox, and Safari. - CDN edge support differs — Cloudflare, Fastly, and CloudFront each parse SWR but normalize
Varydifferently; verify yours. - Service Worker layer (optional) requires the Cache API and
event.waitUntil; it runs before the HTTP cache, so it overrides header semantics when registered. - Origin must emit a meaningful
max-ageand a non-zerostale-while-revalidate; an SWR value of0reverts to plain expiration.
The directive precedence that governs how these interact is detailed in HTTP Cache-Control headers explained — read it first if no-cache and SWR ever coexist in your config.
The Mechanics: Three Phases of a Cached Response
The stale-while-revalidate directive instructs caches to serve a stale response immediately while asynchronously fetching a fresh copy. Browsers evaluate max-age first to set the freshness window, then apply the SWR value as the grace period for background revalidation.
- Fresh window (
0tomax-age): served straight from cache, zero network latency. - Stale window (
max-agetomax-age + stale-while-revalidate): the cached response returns synchronously with TTFB < 10ms and triggers a backgroundGETatLowpriority so it never blocks the critical rendering path. - Post-stale expiration: the stale window has closed; the next request blocks on the origin, reverting to standard cache-miss behavior.
During the stale window, concurrent requests for the same resource are deduplicated by the HTTP cache layer — only one background fetch executes, and all pending requests receive the updated payload when it resolves. If directives conflict (no-cache alongside SWR), browsers prioritize explicit invalidation.
1. Environment Setup & 2. Capture Baseline
Start by recording how each route behaves today so the fix is measurable. Categorize routes into versioned assets, public APIs, and authenticated payloads, then probe each.
# Baseline: capture Cache-Control and the Age header per route.
for p in /assets/app.js /api/v1/public/catalog /dashboard/feed; do
curl -sI "https://your-domain.com$p" | grep -iE 'cache-control|age|vary'
echo "--- $p"
done
# trade-off: curl reads HTTP-cache headers but cannot observe a Service Worker,
# which intercepts fetch BEFORE the HTTP cache. Do NOT conclude SWR is absent
# from these headers alone if a SW is registered — inspect it in DevTools too.
Then validate the live behavior in the browser. In DevTools, open the Network tab, leave Disable cache off, and reload. A stale hit shows Size: 0 B (or from disk cache / from service worker) with a concurrent background fetch visible lower in the waterfall. Record the stale-hit TTFB and the background-fetch completion time — these are your baseline numbers to beat. Target stale-hit TTFB < 100ms and background revalidation completion < 200ms.
3. Isolate Bottleneck: Server-Side & CDN Configuration
Misaligned TTLs across origin, edge, and browser are the most common SWR defect: when the CDN max-age exceeds the origin's, the edge serves stale data beyond the intended window and breaks consistency. Align them top to bottom, then apply SWR per content type.
Dynamic header injection by content type (Nginx)
map $sent_http_content_type $swr_ttl {
default "max-age=60, stale-while-revalidate=300";
~image/ "max-age=31536000, stale-while-revalidate=86400";
~text/css "max-age=31536000, stale-while-revalidate=86400";
~application/javascript "max-age=31536000, stale-while-revalidate=86400";
}
server {
# Use $sent_http_content_type (the response type), not $content_type (the request).
add_header Cache-Control $swr_ttl always;
add_header Vary Accept-Encoding always;
# trade-off: the Vary header avoids compression cache poisoning but raises
# cache-key cardinality. Do NOT add Vary values your CDN does not normalize,
# or you fragment the cache and the "hit" becomes a miss.
}
Route-specific middleware (Express.js)
const swrMiddleware = (req, res, next) => {
const route = req.path;
if (route.startsWith('/api/v1/public')) {
res.set('Cache-Control', 'public, max-age=120, stale-while-revalidate=600');
} else if (route.startsWith('/assets/')) {
res.set('Cache-Control', 'public, max-age=31536000, stale-while-revalidate=2592000');
} else {
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
}
// trade-off: SWR on a public route is unsafe the instant it returns
// personalized data. Do NOT widen the /api/v1/public prefix to cover any
// endpoint that reads a session cookie — cross-user leakage results.
next();
};
app.use(swrMiddleware);
Edge override (Cloudflare Workers)
When origin control is limited, rewrite headers at the edge to decouple frontend tuning from backend deploys.
export default {
async fetch(request) {
const response = await fetch(request);
const headers = new Headers(response.headers);
if (request.url.includes('/static/')) {
headers.set('Cache-Control', 'public, max-age=31536000, stale-while-revalidate=604800');
}
// trade-off: edge rewrites mask the origin's true intent and can drift from
// it silently. Do NOT use them as a permanent substitute for correct origin
// headers — treat them as a bridge until the backend ships the fix.
return new Response(response.body, {
status: response.status, statusText: response.statusText, headers
});
}
};
Static assets should cap the SWR window at ≤ 30 days so a major deploy does not keep serving deprecated JS/CSS; dynamic APIs should cap it at ~10 minutes to bound freshness lag. The deeper question of when SWR at the header layer is the right tool versus revalidating inside the service worker is worked through in SWR Cache-Control vs service worker revalidation, which lays out a decision matrix for each layer.
4. Apply Fix: Service Worker Coordination
HTTP-layer SWR and Service Worker caching operate independently, and a registered worker intercepts fetch before the HTTP cache. To get background revalidation at the SW level, implement cache-first with event.waitUntil.
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
const fetchPromise = fetch(event.request).then((networkResponse) => {
if (networkResponse && networkResponse.ok) {
return caches.open('v1').then((cache) =>
// Clone before storing — consuming the stream breaks the cached copy.
cache.put(event.request, networkResponse.clone())
);
}
});
event.waitUntil(fetchPromise); // keep the SW alive until refresh completes
return cachedResponse;
}
return fetch(event.request);
// trade-off: SW cache-first serves stale on the FIRST paint after a deploy
// until the background fetch lands. Do NOT use it for assets that must be
// exact on first load — gate the hashed shell behind a version handshake.
})
);
});
When multiple tabs request the same stale resource, each triggers an independent background fetch. Deduplicate with BroadcastChannel or clients.matchAll() to coordinate a single request across tabs. The broader fallback-routing patterns for the worker layer live in service worker caching strategies. Always .clone() the Response before writing to the Cache API — consuming the body stream during storage breaks the response delivered to the client.
Deconstructing the Stale-Hit Latency Phases
To budget SWR precisely, split the stale-hit path into phases, each with its own threshold.
- Cache lookup (target ≤ 5ms):
caches.matchor HTTP-cache resolution. Slow here means an oversized cache or excessive key cardinality. - Response delivery (target ≤ 10ms): streaming the cached body to the document. This is the TTFB the user feels.
- Background fetch dispatch (off the critical path): must run at
Lowpriority. If it competes with the main thread it inflates INP past 200ms. - Background write (off the critical path):
cache.putafter.clone(). Heavy serialization here is the classic INP regression; keep it asynchronous and never on the synchronous response path.
Attribute a regression to one phase before tuning. A slow stale hit is a lookup/delivery problem; an interaction stall during a refresh is a priority/write problem.
Advanced Diagnostics: Thresholds, Invalidation, and Edge Cases
SWR is not universal — its safety depends entirely on content volatility and business criticality.
| Content Type | Update Frequency | max-age | stale-while-revalidate | Risk Tolerance |
|---|---|---|---|---|
| Versioned assets (JS/CSS/images) | Per deployment | 31536000 (1yr) | 86400 (1d) | Low |
| Public API / catalog | Hourly/daily | 60-120 | 300-600 | Medium |
| User dashboard / feeds | Real-time | 0-15 | 30-60 | High |
| Authenticated/transactional | Session-bound | 0 (no-cache) | 0 (disabled) | Critical |
Authenticated endpoints: never apply SWR to routes returning Set-Cookie or personalized payloads. Use private, max-age=0, no-cache to force validation.
Predictive overlap: trigger background updates on mouseenter or pointerdown so the next route is already warm in the stale window before navigation.
Emergency invalidation: when an immediate purge is required, deploy versioned URLs (/static/v2.1.0/app.js) rather than overriding Cache-Control. SWR coexists with stale-if-error, which serves stale content only on origin failure rather than proactively. Both lean on a deliberate invalidation strategy; the trade-offs between tag-based purging and versioned URLs are detailed in cache invalidation patterns. For transient flakiness, add a service-worker fallback that serves the stale payload if the background fetch fails within 3000ms.
Validation & Budgeting in CI
Make the freshness and latency thresholds executable so a regression fails the pipeline.
// Assert SWR directives and a meaningful stale window in CI.
import { test, expect } from '@playwright/test';
test('public API ships a non-zero SWR window', async ({ request }) => {
const res = await request.get('https://staging.your-domain.com/api/v1/public/catalog');
const cc = res.headers()['cache-control'] ?? '';
const swr = Number(/stale-while-revalidate=(\d+)/.exec(cc)?.[1] ?? 0);
expect(swr).toBeGreaterThan(0); // a zero window silently disables SWR
expect(cc).not.toContain('no-store');
// trade-off: a header assertion proves config, not behavior. Do NOT treat a
// green test as proof revalidation runs — pair it with a RUM field check.
});
Add a Lighthouse CI budget holding time-to-first-byte at 200ms and largest-contentful-paint at 2500ms. In RUM, instrument a PerformanceObserver on resource entries, filter initiatorType === 'fetch', track responseStart - requestStart, and alert when the SWR-route cache-miss rate exceeds 10% — that is the field signal that the stale window is mistuned.
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
stale-while-revalidate=0 | Disables the SWR window; reverts to plain max-age. | Set a meaningful duration (300 APIs, 86400 assets). |
Ignoring Vary implications | Fragmentation across Accept-Encoding/Accept-Language degrades TTFB for 30-50% of users. | Declare Vary explicitly and confirm CDN normalization. |
| Blocking the main thread on background fetch | Inflates INP from synchronous cache writes. | Use event.waitUntil; keep cache.put off the response path. |
| Over-caching authenticated endpoints | Cross-session data leakage. | Restrict SWR to public; apply private, no-cache to auth routes. |
FAQ
How does `stale-while-revalidate` differ from `stale-if-error`?
SWR serves cached content while proactively refreshing during normal operation; stale-if-error serves stale content only reactively when the origin returns 5xx or fails. They are complementary — one optimizes latency, the other adds fault tolerance.
Can I use SWR for GraphQL?
Yes, but HTTP caching requires GET. Use persisted queries or GET endpoints, then apply SWR at the CDN/origin level. Avoid POST caching unless your CDN normalizes it explicitly.
What SWR duration suits dynamic APIs?
max-age=60-120s with stale-while-revalidate=300-600s is a reasonable start: it holds TTFB under 150ms while bounding freshness lag to ~10 minutes. Tighten it for real-time dashboards.
Related
- Advanced caching strategies and CDN architecture — the parent reference spanning origin, edge, and client layers.
- HTTP Cache-Control headers explained — the directive precedence that governs SWR alongside other rules.
- CDN edge caching configuration — applying SWR windows at the edge tier.
- SWR Cache-Control vs service worker revalidation — a decision matrix for which layer should revalidate.
- Cache invalidation patterns — tag-based purging versus versioned URLs when stale is no longer acceptable.