SWR via Cache-Control vs Service Worker revalidation
This comparison sits within the stale-while-revalidate implementation guide under Advanced Caching Strategies & CDN Architecture, and resolves a recurring architecture question: you want stale-while-revalidate behaviour, but should it live as an HTTP Cache-Control directive enforced at the CDN edge, or as logic inside a Service Worker on the device?
Both deliver the same user-facing promise — instant cached response now, fresh copy next time — but they operate at different layers, with different blast radius, freshness guarantees, and effects on Interaction to Next Paint and Largest Contentful Paint. The targets are the same either way: TTFB ≤ 200ms, LCP < 2.5s, INP < 200ms, and no main-thread task over the 50ms long-task budget.
Where each layer revalidates
With Cache-Control: stale-while-revalidate, the edge owns the logic. The first byte the browser receives still comes over the network, but from the nearest point of presence, and the edge — not the device — handles the background refetch from origin. It is declarative: one header, applied to every visitor of every PoP, with no client code.
With a Service Worker, the device owns the logic. The worker intercepts fetch, answers from the Cache API with zero network on a hit, and issues its own background revalidation. It is imperative: JavaScript you ship and version, running per browser, and it works offline.
Decision matrix
| Dimension | Cache-Control SWR (edge) | Service Worker SWR (device) |
|---|---|---|
| Layer | CDN / shared cache | Per-device, in the browser |
| Hit latency | Network hop to nearest PoP (~20-80ms) | Zero network — reads local Cache API |
| Offline support | None — needs the network | Full — serves from cache offline |
| Freshness control | Coarse: one TTL/window per route | Fine: arbitrary per-request logic |
| Who is served stale | Every visitor on first request after expiry | Only the specific device, per its cache |
| INP impact | None — work happens off-device | Slight — background fetch/put on device |
| LCP impact | Depends on PoP RTT | Best — instant on repeat visits |
| First-visit benefit | Yes — shared cache helps cold clients | No — cache is empty until first fetch |
| Setup complexity | Low — one header / edge rule | High — register, version, debug a worker |
| Failure blast radius | Edge config, easy to roll back | Buggy worker can break the whole site |
When to pick which
Reach for Cache-Control SWR at the edge as the default. It benefits first-time and cold-cache visitors (a shared edge object is warm for everyone), needs no client JavaScript, has a trivial rollback (change the header), and keeps the main thread free — so it never threatens INP. Use it for HTML, API JSON, and any resource where a sub-100ms PoP response is good enough and you do not need offline.
Reach for Service Worker SWR when you specifically need what HTTP cannot give you: true offline support, zero-network repeat-visit reads (an installable PWA or app shell), or per-request revalidation logic too nuanced for a single header — for example revalidating only when a client-side ETag or auth scope changes. Accept that you are shipping, versioning, and debugging code, and that a bug has a large blast radius.
In practice the strongest setups compose both: edge SWR protects the origin and warms cold clients, while a worker layered in front gives returning users offline and instant reads. They are not mutually exclusive — they revalidate at different layers.
The most consequential difference, and the one that decides most real cases, is who gets served stale and for how long. Edge SWR is a shared cache: when the window opens, the very next visitor to that PoP gets the stale object and triggers the single background refresh that then benefits everyone behind that node. The staleness is broad but short-lived and self-healing across the whole audience. Worker SWR is per-device: each browser carries its own copy and its own revalidation cycle, so a user who has not returned in a week is served a week-old object on their next visit regardless of how many other users refreshed in the meantime. For frequently-read shared content, the edge's shared cache converges on freshness far faster; for a returning-after-a-gap individual on a flaky connection, the worker's local copy is the only thing that paints at all. Naming which of those two users you are optimising for usually settles the choice.
There is also an operational asymmetry worth weighing explicitly. An edge header change propagates in seconds and reverts just as fast, and a mistake degrades to "slightly staler than intended" — annoying, rarely catastrophic. A Service Worker, once installed, is sticky: a buggy fetch handler can intercept every request on the origin and a bad deploy can be hard to claw back, because the broken worker is the very thing that controls whether the fixed worker can be fetched. That is why a worker-based approach demands a tested update-and-skipWaiting path and a rehearsed kill switch before it ships, whereas edge SWR carries almost none of that operational tax.
Reference implementations
Edge SWR is a single declarative header:
# Origin/edge response — the CDN owns the background refresh.
Cache-Control: public, max-age=60, stale-while-revalidate=600
# trade-off: the window is one coarse value for all visitors. You cannot
# revalidate "only when the user's permissions changed" — for per-request
# freshness logic you need the worker version below, not this header.
Worker SWR puts the same idea on-device, with explicit control:
// Device-side SWR — zero-network hits, works offline, per-request logic.
async function swr(request) {
const cache = await caches.open('data-v1');
const cached = await cache.match(request);
const fresh = fetch(request).then((res) => {
if (res.ok) cache.put(request, res.clone());
return res;
});
// trade-off: this runs on the device's main-thread event loop. A large
// synchronous response body cloned/parsed here can blow the 50ms long-task
// budget and regress INP — do NOT use it for big payloads without chunking,
// and prefer edge SWR when you don't actually need offline.
return cached || fresh;
}
For the per-strategy choice inside the worker (cache-first vs SWR for a given route in a React app), see the SWR vs cache-first Service Worker comparison.
Verification
Confirm SWR is genuinely active at the layer you chose:
- Edge SWR.
curl -sIthe route twice just after expiry. Expected outcome: both return200fast, the cache-status header flips fromSTALE/REVALIDATINGtoHIT, and the body updates on the second call — proving the edge revalidated in the background, not on the critical path. - Worker SWR. In DevTools, Application, Service Workers, reload and watch the Network tab: a hit shows the
(ServiceWorker)response plus one background request to the same URL. Throttle to Offline and confirm the page still paints. - INP guard (worker only). Record a reload in the Performance panel and confirm the worker's
cache.put/parse callback creates no task over 50ms; defer heavy writes withevent.waitUntilso they leave the critical path. - RUM field check. Compare TTFB p75 for edge SWR and repeat-visit LCP for worker SWR before and after rollout. Expected outcome: edge SWR holds TTFB ≤ 200ms across PoPs; worker SWR drops repeat-visit LCP toward the local-read floor.
- Rollback drill. For edge, revert the header and reconfirm; for the worker, ship a no-op
fetchpassthrough and confirm the site still works — a worker rollback is the riskier of the two, so rehearse it.
Choosing the layer deliberately — and composing them where it pays — is what separates a resilient cache from a fragile one. When either layer hands you a stale object you must actively evict, the cache invalidation patterns guide covers the purge mechanics.
Related
- Stale-while-revalidate implementation — the parent guide on SWR across headers, edge, and client.
- SWR vs cache-first Service Worker for React SPAs — choosing a strategy inside the worker itself.
- Configuring stale-if-error for origin outages — the resilience directive that pairs with edge SWR.
- Cache invalidation patterns — evicting stale objects from either layer after a change.
- Advanced Caching Strategies & CDN Architecture — the broader architecture both layers serve.