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

HTTP-cache SWR vs service-worker SWR request flow Two vertical flows side by side. Left: a request hits the HTTP cache, the stale response is served in about one millisecond, and a background conditional GET refreshes the entry. Right: the same request must first wait for service worker startup of 50 to 500 milliseconds, then a Cache Storage lookup serves the stale copy, and a programmable background fetch refreshes it with offline capability. HTTP-cache SWR (header only) Service-worker SWR (programmable) GET /api/products HTTP cache lookup age inside SWR grace window Stale response served ~1 ms — no JavaScript on the path Background conditional GET If-None-Match, Low priority Cache entry refreshed 304 updates metadata, 200 swaps body GET /api/products Service worker startup cold start 50–500 ms before dispatch fetch event → caches.match() Cache Storage lookup, ~2–10 ms respondWith(cached copy) stale served after startup tax waitUntil(fetch + cache.put) programmable refresh, offline fallback Response delivered to the page Latency the header-only approach never pays Background refresh (off the critical path) Boxes left of the divider run natively in the network stack

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 second Low-priority background request.
  • [ ] 3. Scope the worker’s fetch handler. If a worker is justified, intercept only the routes that need programmability and return early 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() in activate and consume event.preloadResponse in 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 with cache: '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 age response 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