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.
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 |
hash.js · fonts · immutable media
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.
- Application > Service Workers. Tick Update on reload and reload twice so a fresh worker is in control and
navigator.serviceWorker.controlleris non-null. - Network tab, Initiator column. Apply the
swfilter. 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. - Cross-check against the URL shape. Any request whose path matches
*.[hash].js|css|woff2that also fires a background request is mis-routed to SWR. Any request toindex.html,/config.json, or an avatar that shows zero background request is mis-routed to cache-first. - 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.
- Performance panel. Record a reload and look for a service-worker
cache.putcallback 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.
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.
// 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.
// 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.
// 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.
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
.catchfallback 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.putunder SWR is the usual culprit. Defer it withevent.waitUntilso 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.
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.putor 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.
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.