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.
Decision matrix
| Dimension | Cache-first | Stale-while-revalidate |
|---|---|---|
| First-visit freshness | Whatever was cached; can be arbitrarily old | Same stale answer, but refreshed for next visit |
| Network requests per hit | Zero | One background request per hit |
| Offline behaviour | Excellent — never needs network | Good — serves stale, background fetch fails silently |
| INP impact | Lowest — no extra work after response | Slight — background fetch + cache write competes for I/O |
| LCP impact (hit) | Identical to SWR — both paint from cache | Identical to SWR on the hit path |
| Staleness window | Until explicit invalidation/versioning | One visit (next load is fresh) |
| Bandwidth cost | Minimal | Higher — refetches even when unchanged |
| Implementation complexity | Low | Moderate (must guard the background fetch) |
| Best for | Hashed JS/CSS, fonts, immutable media | App 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:
// 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:
// 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:
- Open DevTools, Application, Service Workers, and tick Update on reload. Reload twice.
- 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.
- Throttle to Offline and reload. Both strategies must still paint from cache — if SWR fails, your background-fetch
.catchis missing. - 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.
- In RUM, watch INP p75 for SWR routes after rollout — a regression above 200ms means the background
cache.putis contending with interaction work; defer it withevent.waitUntilso 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.
Related
- Service Worker caching strategies — the parent guide on
fetchinterception and strategy selection. - Debugging Service Worker cache misses in production — when neither strategy is hitting the cache at all.
- SWR via Cache-Control vs Service Worker revalidation — the same revalidation idea, but at the edge.
- Cache invalidation patterns — how cache-first resources eventually get refreshed.
- Advanced Caching Strategies & CDN Architecture — the full caching architecture this fits into.