SWR vs cache-first Service Worker for React SPAs

This comparison sits under the broader Service Worker caching strategies guide within Advanced Caching Strategies & CDN Architecture, and isolates a single decision a React team faces once the worker is already intercepting fetch: should a given route be served stale-while-revalidate or cache-first?

The two strategies look superficially similar — both answer from the Cache API on a hit — but they diverge sharply on freshness, network behaviour, and how they perturb Interaction to Next Paint and Largest Contentful Paint. Picking the wrong one per route is the most common cause of either visible staleness or wasted background bandwidth in a React SPA. The actionable boundaries are unchanged: keep LCP < 2.5s, INP < 200ms, and any work the worker schedules on the main thread under the 50ms long-task budget.

How each strategy resolves a request

Cache-first checks the Cache API first and returns the cached Response if present, touching the network only on a miss. Once a response is stored, the user never sees a newer version until you explicitly invalidate or version the cache key.

Stale-while-revalidate (SWR) also returns the cached response immediately, but it additionally kicks off a background fetch to the origin and writes the fresh response into the cache for next time. The current navigation is just as fast as cache-first; the difference is the extra request and the eventual freshness it buys.

Cache-first vs stale-while-revalidate flow Both serve from cache instantly; SWR adds a background revalidation fetch that updates the cache. Request handling per strategy Cache-first Cache hit Return cached No network on hit Stale-while-revalidate Cache hit Return cached Background refetch + store Same time-to-paint; SWR trades extra bandwidth for next-visit freshness.

Decision matrix

DimensionCache-firstStale-while-revalidate
First-visit freshnessWhatever was cached; can be arbitrarily oldSame stale answer, but refreshed for next visit
Network requests per hitZeroOne background request per hit
Offline behaviourExcellent — never needs networkGood — serves stale, background fetch fails silently
INP impactLowest — no extra work after responseSlight — background fetch + cache write competes for I/O
LCP impact (hit)Identical to SWR — both paint from cacheIdentical to SWR on the hit path
Staleness windowUntil explicit invalidation/versioningOne visit (next load is fresh)
Bandwidth costMinimalHigher — refetches even when unchanged
Implementation complexityLowModerate (must guard the background fetch)
Best forHashed JS/CSS, fonts, immutable mediaApp shell HTML, avatars, config JSON, feed thumbnails

When to pick which

Pick cache-first for any resource whose URL already encodes its content — content-hashed bundles, fonts, immutable images. A new deploy produces a new URL, so there is no staleness to revalidate against, and the background request SWR would issue is pure waste. This is also the right default for offline-first React shells where predictability matters more than freshness.

Pick SWR for resources that share a stable URL but whose body changes — the unhashed index.html app shell, a user avatar at /me/avatar.png, a /config.json, or non-critical list thumbnails. You accept one revision of staleness in exchange for the cache silently healing itself without an explicit purge.

For genuinely freshness-critical data (cart totals, auth state, prices) neither pattern is correct — use network-first with a cache fallback, because showing a stale price even once is unacceptable.

The subtle trap specific to React SPAs is that the build emits a small number of long-lived, hashed bundles plus one short-lived index.html that references them by hash. Teams reflexively apply a single strategy to everything the worker intercepts and then wonder why a deploy either fails to roll out (cache-first on index.html pins users to a stale entry point that loads now-purged bundles) or feels wasteful (SWR on app.8f3a9c.js refetches bytes that are immutable by construction). The correct mental model is per-resource, not per-app: hash-versioned URLs are cache-first because their content can never change under a fixed URL, and the single mutable entry document is SWR so a new deploy is picked up within one navigation. Getting this split right is what makes a worker-backed React app deploy cleanly and feel instant.

A second React-specific consideration is hydration timing. Both strategies paint the cached shell at the same speed, but SWR's background fetch lands while React is hydrating and attaching event listeners. If that fetch resolves into a state update mid-hydration, you can trigger an extra render pass right as the user is trying to interact — exactly the window that shows up as elevated INP. Cache-first sidesteps this entirely because nothing arrives after the initial paint. When you do choose SWR for a route that feeds React state, gate the state update behind a check that the data actually changed, so an unchanged background response does not force a re-render for nothing.

Reference implementations

A hand-rolled cache-first handler, scoped to hashed assets:

javascript
// Cache-first: answer from cache, hit network only on a miss.
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  if (!/\.[0-9a-f]{8,}\.(js|css|woff2)$/.test(url.pathname)) return;
  event.respondWith(
    caches.match(event.request).then((cached) =>
      cached || fetch(event.request).then((res) => {
        const copy = res.clone();
        caches.open('assets-v1').then((c) => c.put(event.request, copy));
        return res;
      })
    )
  );
  // trade-off: never revalidates — only safe for content-hashed URLs.
  // Apply this to index.html and users will be stuck on an old build forever.
});

A stale-while-revalidate handler for the mutable app shell:

javascript
// SWR: serve cached immediately, refresh in the background for next time.
async function staleWhileRevalidate(request, cacheName = 'shell-v1') {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  const network = fetch(request)
    .then((res) => {
      if (res.ok) cache.put(request, res.clone());
      return res;
    })
    .catch(() => cached); // offline: fall back to whatever we had
  return cached || network;
  // trade-off: issues a network request on every hit. Do NOT use for
  // immutable hashed assets — it burns bandwidth refetching bytes that
  // cannot have changed, and offers zero freshness benefit in return.
}

If you are on Workbox rather than a hand-rolled worker, the same decision maps directly onto CacheFirst and StaleWhileRevalidate route handlers; the matrix above still governs which to register per registerRoute.

Verification

Confirm each route resolves with the strategy you intended before shipping:

  1. Open DevTools, Application, Service Workers, and tick Update on reload. Reload twice.
  2. In the Network tab, set the Size column to visible. A cache-first hit shows (ServiceWorker) with no matching origin request. An SWR hit shows the (ServiceWorker) response plus a background request to the same URL.
    • Expected outcome: hashed assets show zero background requests; the app shell shows exactly one.
  3. Throttle to Offline and reload. Both strategies must still paint from cache — if SWR fails, your background-fetch .catch is missing.
  4. In the Performance panel, record a reload and confirm no service-worker callback creates a task over the 50ms long-task budget; an oversized synchronous cache write under SWR is the usual culprit.
  5. In RUM, watch INP p75 for SWR routes after rollout — a regression above 200ms means the background cache.put is contending with interaction work; defer it with event.waitUntil so it runs off the critical path.

Choosing per route, rather than globally, is what keeps a React SPA both fast and fresh. For the deeper invalidation question that cache-first eventually forces, see the cache invalidation patterns guide.