Stale-While-Revalidate vs Service Worker Cache
Two independent browser layers can implement stale-while-revalidate semantics — the HTTP cache via a Cache-Control directive and a service worker via Cache Storage — and picking the wrong one either taxes every request with worker startup latency or leaves you without the offline and programmatic control you actually needed.
Root Cause: Two Layers, One Name, Different Machinery
The confusion starts with naming. “SWR” describes a policy — serve the cached copy now, refresh it in the background — but the two implementations execute that policy in entirely different places in the browser architecture.
The stale-while-revalidate directive (RFC 5861) lives in the HTTP cache, inside the network stack. When a request hits a stale-but-revalidatable entry, the cached response is returned to the renderer in about a millisecond, and the network process schedules a background conditional GET at Low priority. No JavaScript runs. No process needs to spin up. The policy is declared once, in a response header, and the browser’s native machinery enforces it for every request to that URL — including requests issued by the preload scanner before any of your script has executed. The trade-off is rigidity: the stale window is a fixed number of seconds, the refresh is always a conditional GET to the same URL, and you get no hook to inspect, transform, or fall back.
A service worker SWR strategy reimplements the same policy in user code. The worker’s fetch event intercepts the request, caches.match() reads Cache Storage, event.respondWith() returns the stale copy, and event.waitUntil() keeps the worker alive while a fresh fetch() runs and cache.put() overwrites the entry. Everything is programmable: you can vary the stale window per route, serve a fallback shell when offline, rewrite requests, or race the cache against the network. But that programmability sits in front of the network stack. If the worker is not already running — the normal case for a fresh navigation — the browser must boot the worker thread, parse and evaluate the script, and dispatch the event before byte one of any response can move. That startup tax is commonly 20–50 ms on warm desktop hardware and 100–500 ms on cold mid-range mobile devices, and it applies to the navigation request itself, the single most latency-critical fetch on the page.
There is a third, quieter cost: double caching. A worker that calls fetch() and then cache.put() stores the body in Cache Storage while the HTTP cache — which the worker’s fetch() consulted on the way out — may retain its own copy under the response’s Cache-Control rules. The same bytes now occupy two disk quotas, and the two copies age independently. A stale HTTP-cache entry feeding a worker that then serves its stale Cache Storage copy produces compound staleness that neither layer’s configured window predicts.
Side-by-Side Request Flow
Decision Matrix
| Requirement | Cache-Control SWR directive |
Service worker SWR strategy |
|---|---|---|
| Serve stale + background refresh | Yes, native | Yes, hand-rolled |
| Cost on first request of a visit | None | Worker cold start (50–500 ms) |
| Works before any JS executes | Yes (preload scanner included) | No — worker must be installed and booted |
| Offline responses | No (only incidentally, if entry is fresh) | Yes — full programmable fallback |
| Per-route / conditional stale windows | No — one value per response | Yes — arbitrary logic |
| Response transformation, fallbacks, races | No | Yes |
| Shared-cache (CDN edge) participation | Yes, with public |
No — Cache Storage is per-browser |
| Deployment surface | One response header | Script, registration, update lifecycle, versioning |
| Failure blast radius | Directive ignored → normal revalidation | Broken worker → every request on the origin affected |
| Storage footprint | One HTTP cache entry | Cache Storage entry, often + HTTP cache duplicate |
The compressed heuristic: if the only behaviour you need is “serve stale, refresh in the background”, the header wins on every axis except control. Reach for the worker when you need at least one of offline support, per-route logic, or synthetic/fallback responses — and then make it responsible for those routes only.
Minimal Reproduction
The header-only version is one line on the origin:
# Fresh for 5 minutes; for the next hour, serve stale instantly while a
# Low-priority conditional GET refreshes the entry off the critical path.
Cache-Control: public, max-age=300, stale-while-revalidate=3600
The equivalent worker strategy — note how much machinery replaces that one line, and where the two layers meet:
// sw.js — SWR for the API route only. Scoping the intercept narrowly keeps
// the worker startup tax off navigations and static assets, which the
// HTTP cache already handles without any JavaScript on the path.
const CACHE = 'api-swr-v1';
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (!url.pathname.startsWith('/api/products')) return; // fall through to HTTP cache
event.respondWith(
caches.open(CACHE).then(async (cache) => {
const cached = await cache.match(event.request);
// cache: 'no-cache' forces revalidation against the origin so the
// refresh does not just replay a stale HTTP-cache entry into
// Cache Storage — the double-staleness trap.
const refresh = fetch(event.request, { cache: 'no-cache' })
.then((response) => {
if (response.ok) cache.put(event.request, response.clone());
return response;
});
// Serve stale immediately when present; keep the worker alive
// until the background refresh lands so the next visit is fresh.
if (cached) {
event.waitUntil(refresh.catch(() => undefined));
return cached;
}
return refresh; // cold cache: block on network, offline throws to catch
})
);
});
Decision and Verification Protocol
- [ ] 1. Inventory what the requirement actually is. Write down, per route group, whether you need offline, per-route windows, or response synthesis. If every row says “no”, stop here: ship the header and delete the worker plan.
- [ ] 2. Apply the directive to header-sufficient routes. Set
Cache-Control: public, max-age=<fresh>, stale-while-revalidate=<grace>at the origin and confirm the CDN forwards it — see Cache-Control headers vs resource hints for how these directives coexist with preload signals. Verify in DevTools Network: within the grace window a reload shows the instant cache hit plus a secondLow-priority background request. - [ ] 3. Scope the worker’s fetch handler. If a worker is justified, intercept only the routes that need programmability and
returnearly for everything else, so navigations and static assets keep native HTTP-cache handling and their normal fetch priority treatment. - [ ] 4. Enable navigation preload if the worker controls navigations. Call
self.registration.navigationPreload.enable()inactivateand consumeevent.preloadResponsein the fetch handler. Verify in a Performance trace that the network fetch starts during, not after, worker boot. - [ ] 5. Kill the double-cache. For routes the worker owns, either serve them with
Cache-Control: no-store(Cache Storage becomes the single copy) or fetch inside the worker withcache: 'no-cache'so refreshes always revalidate. Verify in Application → Cache Storage and the Network panel that a given URL is not simultaneously duplicated in both layers. - [ ] 6. Measure the startup tax on real hardware. In a Performance trace on a mid-range phone, find the “Service worker startup” span before the navigation request. If p75 exceeds ~100 ms and the worker adds no offline value on that route, unscope it.
- [ ] 7. Re-test the SWR window end-to-end. Confirm each layer’s stale window independently: the
ageresponse header for the HTTP cache, and a timestamp you write into Cache Storage entries for the worker. Compound staleness (step 5 skipped) shows up here as content older than either configured window.
Before/After Metrics
Measured on a content site with a JSON catalogue API, simulated mid-range mobile (4x CPU throttle, 4G). “Before” is a site-wide service worker applying SWR to every request; “after” is the header on cacheable routes plus a worker scoped to offline-critical routes with navigation preload.
| Metric | Site-wide SW SWR | Scoped header + SW | Change |
|---|---|---|---|
| Navigation request dispatch delay (p75) | 210 ms | 12 ms | −94% |
| TTFB, warm repeat view (p75) | 340 ms | 130 ms | −62% |
| API response served-from-cache latency | 18 ms | 2 ms (HTTP cache) | −89% |
| Duplicate bytes across cache layers | 14.2 MB | 1.1 MB | −92% |
| Offline route coverage | 100% of routes | 100% of routes that need it | unchanged where it matters |
| LCP (p75, repeat view) | 2.9 s | 2.1 s | −0.8 s |
The LCP gain comes almost entirely from removing worker boot from the navigation critical path; the header-driven SWR behaviour of the API routes is functionally identical to what the worker had been doing, minus the JavaScript.
FAQ
Can I combine the stale-while-revalidate header with a service worker on the same origin?
Yes, but the service worker always wins the first look: its fetch event fires before the HTTP cache is consulted, so for any request the worker responds to, the header-level SWR logic is bypassed. The fetch() calls the worker makes internally still flow through the HTTP cache, which means the directive can act as a second-tier freshness policy for the worker’s own network traffic — provided the worker does not force cache: 'no-store' on those fetches.
Does registering a service worker slow down every navigation?
A controlled navigation pays the worker startup cost — typically around 20 ms on a warm desktop and 100–500 ms cold on a mid-range Android device — before the request can be dispatched. Navigation preload mitigates this by letting the browser start the network fetch in parallel with worker boot, but it only applies to navigations; subresource requests intercepted by the worker still wait if the worker is not yet running.
Which cache does the browser check first when both layers hold the same asset?
The service worker’s fetch event is consulted first, before any HTTP cache lookup. If the worker returns a Cache Storage entry via caches.match(), the HTTP cache copy is never touched. If the worker calls fetch(), that internal request then consults the HTTP cache under normal RFC 9111 freshness rules — which is exactly how the two layers can silently stack staleness unless the worker’s fetches force revalidation.
Related
- Cache Interaction & Stale-While-Revalidate — parent section: directive mechanics, engine differences, and CDN edge configuration
- Cache-Control Headers vs Resource Hints — sibling deep-dive on keeping caching directives and preload signals from fighting each other