Invalidating Service Worker Cache After Deploy
Symptom: minutes after a production deploy, a subset of users — almost always the ones who had the site open in a background tab before the deploy — start reporting a broken page. DevTools shows:
Uncaught (in promise) ChunkLoadError: Loading chunk reports failed.
(missing: https://app.example.com/static/js/route-reports.f4a8c1.js)
or, worse, no error at all: the page loads a mix of old and new JavaScript and throws a confusing runtime exception deep inside application code, because the old shell HTML — served from the service worker’s cache — references a hashed vendor chunk from the previous deploy that the CDN no longer serves.
GET https://app.example.com/static/js/vendor-a1b2c3.js 404 (Not Found)
Uncaught TypeError: Cannot read properties of undefined (reading 'createContext')
Both symptoms trace back to the same cause: a service worker cached the pre-deploy HTML shell and its referenced asset URLs, and nothing has told it that a new deploy exists.
Root cause: the service worker outlives the deploy that created it
A route-based code splitting setup relies on content-hashed filenames — route-reports.f4a8c1.js — so that the CDN and browser HTTP cache can treat each chunk as immutable and cache it for a year without ever re-validating. This works perfectly for the HTTP cache, which is scoped to a single page load and always re-checks the entry HTML document. A service worker breaks that assumption because it is explicitly designed to persist across page loads and across deploys — that is its entire purpose for enabling offline support and instant repeat visits. Once a service worker precaches the entry HTML and its referenced chunk URLs, it will keep serving that exact snapshot to every subsequent request matching its cache-first strategy, including requests made after a new deploy has removed those old chunk files from the CDN entirely.
This is the SSR- and RSC-adjacent failure mode flagged in the parent cluster, code splitting for SSR and React Server Components: SSR apps lean on aggressive caching — including service workers — to offset server render cost, which means they’re disproportionately exposed to this exact class of staleness. The fix has three independent parts: version the precache manifest so the worker knows something changed, force the new worker to take control quickly, and purge the caches that no longer match the current manifest.
It’s worth being precise about why this only bites some users. A visitor who closes their tab and opens a fresh one after a deploy gets the new HTML shell straight from the network — the service worker’s install/activate lifecycle runs, sees a new manifest, and swaps in cleanly before that visitor ever requests a lazy chunk. The failure is specific to a tab that was already open, already registered against the old worker, and only later triggers a route transition that calls import() for a chunk the CDN has since deleted. The longer a session stays open — dashboards, admin panels, anything a user tends to leave running in a pinned tab — the larger this window gets, and the same route-level splitting that keeps initial payload small is exactly what creates the opportunity for a stale reference, because the chunk for an infrequently visited route might not be requested until long after the worker that cached its old URL should have been replaced.
Exact fix
1. Version the precache manifest with Workbox
Workbox’s precacheAndRoute expects a manifest of { url, revision } pairs generated at build time. If your build already emits content-hashed filenames, the hash itself acts as the revision — Workbox’s webpack and Vite plugins pick this up automatically when pointed at the compiled output.
// workbox-build.config.js — Workbox 7 precache manifest generation (Webpack 5 build)
const { generateSW } = require('workbox-build');
generateSW({
globDirectory: 'dist/',
globPatterns: ['**/*.{html,js,css}'],
swDest: 'dist/sw.js',
// Content-hashed filenames already encode a revision — no extra revisioning step needed
dontCacheBustURLsMatching: /\.[0-9a-f]{8}\.(js|css)$/,
cleanupOutdatedCaches: true,
});2. Take control immediately with skipWaiting and clientsClaim
By default, a new service worker sits in a “waiting” state until every tab running the old worker closes — which can take hours or days for users who never close their browser. skipWaiting() and clients.claim() collapse that delay to the next activation cycle.
// sw.js — custom service worker lifecycle handlers
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { clientsClaim } from 'workbox-core';
self.skipWaiting();
clientsClaim();
precacheAndRoute(self.__WB_MANIFEST); // injected by the Workbox build step above
self.addEventListener('activate', (event) => {
event.waitUntil(cleanupOutdatedCaches());
});3. Purge caches that no longer match the current manifest
cleanupOutdatedCaches() handles Workbox-managed precaches, but any hand-rolled runtime cache — a cacheFirst handler for route chunks that isn’t part of the precache manifest — needs its own explicit purge keyed to a cache-name version:
// sw.js — versioned runtime cache purge for hand-rolled cache groups
const RUNTIME_CACHE_VERSION = 'route-chunks-v14'; // bump on every deploy that changes chunk contents
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key.startsWith('route-chunks-') && key !== RUNTIME_CACHE_VERSION)
.map((key) => caches.delete(key))
)
)
);
});4. Enable navigation preload to offset the activation cost
Forcing faster activation has a side effect: the browser has to boot the service worker before it can decide how to answer a navigation request, adding latency to every page load. Navigation preload starts the actual network fetch in parallel with worker startup, so the fetch handler can await it instead of stalling.
// sw.js — navigation preload for faster post-activation navigations
self.addEventListener('activate', (event) => {
event.waitUntil(self.registration.navigationPreload.enable());
});
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
(async () => {
const preloaded = await event.preloadResponse;
return preloaded || fetch(event.request);
})()
);
}
});Step-by-step verification
- Confirm the manifest revision changed. After a build, diff
dist/sw.js(or wherever your generated manifest lives) against the previous deploy’s version. Every changed file should have a new hash; unchanged files should keep their old hash, confirming deterministic chunk hashing is still working upstream of the service worker. - Deploy and force an update without closing the tab. In Chrome DevTools → Application → Service Workers, click “Update” rather than reloading, to simulate a background tab picking up the new worker on its normal update cycle.
- Check that skipWaiting/clientsClaim fired. The Service Workers panel should show the new worker move directly to “activated and is running” without lingering in “waiting to activate.”
- Inspect Cache Storage. Under Application → Cache Storage, confirm the old
route-chunks-v13(or equivalent) key is gone and only the current version’s cache remains. - Reload and check the Network panel. Every chunk request should resolve to a current-hash filename with a
200(from cache or network) — zero404responses and zeroChunkLoadErrorentries in the console. - Repeat with the tab left open across the deploy. This is the scenario that actually reproduces the bug — reloading a freshly opened tab rarely does, because it always fetches the latest HTML. Leave a tab open, deploy, wait 60 seconds, then trigger a lazy-loaded route inside that same tab.
Edge cases and gotchas
Gotcha 1 — dontCacheBustURLsMatching misconfigured for non-hashed assets
Workbox’s dontCacheBustURLsMatching tells it to trust a filename’s existing hash instead of appending its own revision query string. If any of your assets are served without content hashes — a favicon.ico, a manifest.json referenced by absolute path — the same regex can accidentally match them and skip revisioning entirely, meaning a change to that file after deploy will not bust the SW’s copy of it. Scope the regex tightly to hashed JS/CSS filenames only, as shown above, and let everything else get its revision from Workbox’s default content-hash-of-the-file behavior.
Gotcha 2 — a prompt-to-reload UX fights with clientsClaim
Some teams intentionally avoid skipWaiting()/clients.claim() in favor of a “New version available — reload?” banner, to avoid an abrupt logic swap mid-session for apps with complex client state. Mixing both strategies — claiming clients immediately and showing a stale reload banner — produces a confusing UX where the banner appears after the update has already silently applied. Pick one: either claim immediately and accept the mid-session logic swap (safe for most code-split web apps, per the FAQ below), or gate on user consent and skip clients.claim() until the user acts.
Gotcha 3 — this failure looks identical to a plain CDN cache-purge bug
A stale service worker and a CDN that served a cached HTML shell past its TTL produce the exact same symptom — an old chunk reference pointing at a file that no longer exists. Don’t assume it’s the service worker without checking Cache Storage first; if Cache Storage is empty or the site has no service worker registered, the bug is upstream, at the CDN or origin cache-control layer. The general shape of this failure, independent of which caching layer caused it, is covered from the bundler side in fixing ChunkLoadError after a new deploy — read that alongside this page if the service worker checks above come back clean. That page also covers the client-side retry-and-reload recovery pattern for a user who is already mid-session when a chunk goes missing — a necessary complement to the fixes above, because even a perfectly configured service worker cannot protect a request that is already in flight at the exact moment a deploy removes the file it’s asking for.
Gotcha 4 — vendor chunks age differently than route chunks
If your vendor chunk isolation strategy produces long-lived, rarely-changing vendor bundles, a runtime cache rule scoped only to route-*.js patterns will miss vendor chunks entirely, leaving them to whatever default caching the browser negotiates with the CDN. That’s usually fine — vendor hashes rarely change — but the one time it isn’t fine is a dependency security patch that forces a vendor chunk to rotate outside the normal release cadence. Include vendor chunk patterns in the same versioned purge logic as route chunks rather than assuming their stability makes them exempt from the cleanup routine.
FAQ
Why does clearing the browser cache not fix a stale service worker?
The Cache Storage API that service workers use is a separate store from the browser’s HTTP cache, and most ‘clear cache’ actions in browser settings target the HTTP cache and cookies, not Cache Storage. A service worker keeps serving its own cached responses until it is updated, activated, and its old cache entries are explicitly deleted in the activate handler — none of which a manual cache clear triggers.
Is skipWaiting() always safe to call?
It is safe for stateless, code-split web apps where activating a new worker mid-session just means the next chunk fetch uses updated logic. It is riskier for apps with complex client-side state or long-running background sync, where an abrupt switch to new cache-handling logic mid-session can produce inconsistent reads. In those cases, prompt the user to reload instead of claiming clients immediately.
What does navigation preload actually fix?
Without navigation preload, the browser must boot the service worker and let its fetch handler decide what to do before the navigation request itself is even sent, adding the worker’s startup time to every page load. Navigation preload starts the network request in parallel with worker startup, so the fetch handler can await the preload response instead of issuing a second, delayed fetch.
Can I just unregister the service worker to fix stale chunks?
Unregistering removes the service worker but does not automatically clear its Cache Storage entries, and it removes the offline and repeat-visit performance benefits the worker was added for in the first place. It’s an acceptable emergency mitigation during an incident, but the durable fix is correct precache versioning and lifecycle handling, not removing the worker.
Related
- Code Splitting for SSR and React Server Components — the parent cluster covering the broader caching and hydration pressures SSR apps introduce
- Using dynamic import() in React Server Components — a companion guide for the ssr:false build error this page’s fixes ship alongside
- Fixing ChunkLoadError After a New Deploy — the bundler-side counterpart to this service-worker-side failure
- Stabilizing Chunk Hashes to Maximize Cache Hits — the upstream hashing discipline that makes precache revisioning meaningful
- Route-Based Code Splitting & Dynamic Import Strategies — the section defining the chunk boundaries and naming this page’s cache invalidation strategy protects