SWR vs cache-first Service Worker for React SPAs

This comparison sits under the broader Service Worker caching strategies guide, part of the wider work on advanced caching and CDN architecture. It 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

Per-resource strategy decision tree Two questions route each request: a content-hashed or immutable URL goes cache-first; a resource whose stale value is a correctness bug goes network-first; everything else goes stale-while-revalidate. Which caching strategy for each resource? Incoming route request Q1 · URL content-hashed / immutable? yes Cache-first app.hash.js · fonts · immutable media no Q2 · Stale value is a correctness bug? yes Network-first cart totals · auth state · prices no Stale-while-revalidate index.html shell · config.json · avatars Route per resource, not per app: match each URL's mutability to a strategy.

Rapid diagnosis: which strategy is each route actually using?

Before changing any handler, confirm what the worker does today per route. A wrong strategy is invisible until a deploy fails to roll out or a RUM regression appears, so read the live behaviour first.

  1. Application > Service Workers. Tick Update on reload and reload twice so a fresh worker is in control and navigator.serviceWorker.controller is non-null.
  2. Network tab, Initiator column. Apply the sw filter. A cache-first hit shows the (ServiceWorker) response with no matching origin request; an SWR hit shows the (ServiceWorker) response plus a second background request to the same URL.
  3. Cross-check against the URL shape. Any request whose path matches *.[hash].js|css|woff2 that also fires a background request is mis-routed to SWR. Any request to index.html, /config.json, or an avatar that shows zero background request is mis-routed to cache-first.
  4. Application > Cache Storage. Confirm hashed assets and the mutable shell live in separate, versioned cache buckets — a single shared bucket is the tell that one strategy is applied blindly to everything.
  5. Performance panel. Record a reload and look for a service-worker cache.put callback landing during React hydration; a task over the 50ms long-task budget here is the signature of an SWR write contending with interaction work.

The goal of this pass is not merely to confirm a problem exists but to assign each route to one of the failure modes below before you touch a handler.

Root cause analysis

1. Cache-first on the mutable entry document

A React build emits a small number of long-lived, hashed bundles plus one short-lived index.html that references them by hash. Serving index.html cache-first pins users to a stale entry point that keeps requesting now-purged bundle hashes, so a deploy never reaches them and asset requests start 404-ing.

2. SWR on content-hashed assets

app.8f3a9c.js can never change under its URL — a new build mints a new hash. Applying SWR to it issues a background refetch on every hit that returns byte-identical content, burning bandwidth and a cache.put for zero freshness benefit.

3. SWR revalidation landing mid-hydration

Both strategies paint the cached shell at the same speed, but SWR's background fetch resolves while React is hydrating and attaching listeners. If that response feeds a state update, it forces an extra render pass in exactly the window the user is trying to interact — the classic elevated-INP signature.

4. Either strategy on freshness-critical data

Cart totals, auth state, and prices must never render one revision stale. Cache-first shows an arbitrarily old value; SWR shows a one-revision-old value on first paint. For this class of data both are correctness bugs, not cosmetic delays — the fix is a different strategy entirely.

These are not equally likely. Failure mode 1 is the highest-severity because it silently breaks deploys; mode 2 is the most common waste; mode 3 is the subtlest and only shows up in field INP; mode 4 is a design error that a matrix lookup prevents. Diagnose the signature before writing a fix so you do not, for example, rescope a whole app to SWR to solve what was really a single mis-routed document.

Step-by-step resolution

Apply these in order — the first fix resolves the highest-severity failure mode and makes the rest mechanical.

Fix 1 — Route per resource, not per application (highest impact)

The correct mental model is per-resource: 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. Split the routing at the top of the fetch handler.

javascript
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  const isHashedAsset = /\.[0-9a-f]{8,}\.(js|css|woff2)$/.test(url.pathname);
  if (isHashedAsset) {
    event.respondWith(cacheFirst(event.request));      // immutable → cache-first
  } else if (url.pathname === '/' || url.pathname.endsWith('.html')) {
    event.respondWith(staleWhileRevalidate(event.request)); // shell → SWR
  }
  // trade-off: anything not matched here falls through to the network. That is
  // deliberate — do NOT add a catch-all branch, or you will cache API responses
  // that belong under network-first (see Fix 5).
});

Expected outcome: a new deploy is picked up within one navigation while hashed bundles stop refetching — deploys roll out cleanly and the app still paints instantly.

Fix 2 — Cache-first for content-hashed assets

Scope a hand-rolled cache-first handler to hashed bundles, fonts, and immutable media.

javascript
// Cache-first: answer from cache, hit network only on a miss.
async function cacheFirst(request, cacheName = 'assets-v1') {
  const cached = await caches.match(request);
  if (cached) return cached;
  const res = await fetch(request);
  const cache = await caches.open(cacheName);
  cache.put(request, res.clone());
  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.
}

Expected outcome: zero background requests for immutable assets, cutting per-hit bandwidth to nothing and keeping the INP path clear.

Fix 3 — Stale-while-revalidate for the mutable shell

Use SWR for resources that share a stable URL but whose body changes — the unhashed index.html, a /config.json, or a user avatar at /me/avatar.png.

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.
}

Expected outcome: the cache silently heals itself within one visit without an explicit purge, so a new shell rolls out without a manual invalidation.

Fix 4 — Guard the SWR state update during hydration

When an SWR route feeds React state, gate the update behind a content check so an unchanged background response cannot force a re-render mid-hydration.

javascript
// In the page: only commit if the background response is genuinely newer.
async function refreshConfig(setConfig, lastETag) {
  const res = await fetch('/config.json');
  if (res.headers.get('ETag') === lastETag) return; // unchanged → no re-render
  setConfig(await res.json());
  // trade-off: relies on the origin sending a stable ETag. Without one, fall
  // back to a shallow deep-equal on the parsed body before calling setConfig —
  // never dispatch the raw response unconditionally during hydration.
}

Expected outcome: unchanged revalidations no longer trigger a render pass, removing the SWR-specific INP spike from the p75.

Fix 5 — Network-first for freshness-critical data

For cart totals, auth state, and prices, neither cached strategy is correct. Serve network-first with a cache fallback so a stale value never reaches the user while online.

javascript
async function networkFirst(request, cacheName = 'data-v1') {
  try {
    const res = await fetch(request);
    const cache = await caches.open(cacheName);
    cache.put(request, res.clone());
    return res;
  } catch {
    return caches.match(request); // offline only: last-known value
  }
  // trade-off: every hit waits on the network, so this is the slowest path.
  // Reserve it for data where a stale value is a correctness bug, not for the shell.
}

Expected outcome: freshness-critical routes always reflect the origin online, with a graceful last-known fallback only when offline.

If you are on Workbox rather than a hand-rolled worker, the same split maps directly onto CacheFirst, StaleWhileRevalidate, and NetworkFirst route handlers; the matrix above still governs which to register per registerRoute. The deeper question cache-first eventually forces — how those pinned assets ever get refreshed — is covered in cache invalidation patterns.

Verification

Confirm each route resolves with the strategy you intended, then lock the split in CI.

  • Network initiator diff. With Update on reload on, reload twice: hashed assets must show zero background requests; the app shell must show exactly one. That before/after — a shared bucket refetching everything versus a clean split — is the primary signal the routing took.
  • Offline reload. Throttle to Offline and reload. Both cached strategies must still paint; if SWR fails, your background-fetch .catch fallback is missing.
  • Long-task check. Record a Performance reload and confirm no service-worker callback creates a task over the 50ms long-task budget — an oversized synchronous cache.put under SWR is the usual culprit. Defer it with event.waitUntil so it runs off the critical path.
  • CI assertion. Fail the build if the worker audit regresses, so a future refactor cannot silently reintroduce a catch-all strategy.
javascript
module.exports = {
  ci: { assert: { assertions: { 'service-worker': 'error' } } },
};
// trade-off: this asserts a worker is registered and controls the page, not
// that the per-route split is correct. Pair it with an integration test that
// asserts a hashed asset fires no second request and the shell fires exactly one.
  • RUM field validation. Watch INP p75 for SWR routes after rollout; a regression above 200ms means a background cache.put or an unguarded state update is contending with interaction work — revisit Fix 4. Choosing per route, rather than globally, is what keeps a React SPA both fast and fresh.