Service Worker — Users Stuck on the Old Version After Deploy
Fix users permanently served stale assets by a Service Worker cache, understand update activation semantics, and deploy versioned caches with proper cleanup.
Symptoms
- Deployed changes never appear for some users until they hard-refresh or clear site data
- The Network panel shows assets served from ServiceWorker cache with old timestamps
- Skipping waiting is configured, but clients keep running the previous page version indefinitely
Possible causes
- The worker caches the shell and serves cache-first, so new HTML and assets never reach returning visitors
- A new worker installs but never activates because an old client keeps it in waiting, and the page never reloads
- Cache names are unversioned, so the new worker writes into the same cache the old one reads
- Clients claim and skip-waiting logic is missing or incomplete, leaving the update dance half-finished
Diagnosis steps
Inspect worker state
DevTools → Application → Service Workers → check "activated and is running" vs "waiting to activate"Interpretation
A waiting worker means the new version installed but the old one still controls clients — the update stalled at activation.
Check cache names and strategy
DevTools → Application → Cache Storage → list cache names and entry datesInterpretation
Unversioned cache names or old-dated entries under cache-first show returning users get the frozen copy.
Trace the update path
chrome://serviceworker-internals or sw registration update log in DevToolsInterpretation
The browser checks for updates on navigation and push; a byte-identical sw.js never triggers the update flow.
Root cause
Service Workers control pages until explicitly updated: a new worker installs in parallel but waits while any client is held by the old one. Combined with a cache-first strategy and unversioned caches, returning visitors get the frozen old shell, and the update handshake stalls at the waiting stage.
Fix
Version the cache name per deploy, delete old caches during activation, cache-first only immutable hashed assets while network-first (or stale-while-revalidate) the HTML, call skipWaiting in the new worker plus clients.claim when immediate control is wanted, and prompt reload in the page when an update is ready.
Risk note
skipWaiting with in-flight page state can mix old page code with new worker responses mid-session. Prompt a reload instead of hot-swapping stateful sessions.
Verify the fix
Two consecutive deploys show: new worker activates, old caches are gone from Cache Storage, returning clients load the new version within one navigation.
Symptom walkthrough
The Application panel tells you exactly where the update stalled:
Service Workers
▸ activated and is running sw.js (old)
▸ waiting to activate sw.js (new)
A waiting worker means install succeeded and activation never happened — the old one still controls open clients, and nothing reloads them. Meanwhile Cache Storage shows entries from last month under an unversioned name, and the fetch handler answers cache-first: returning users receive the frozen shell no matter what was deployed.
Fix in practice
Complete the update dance properly:
// sw.js — versioned cache, activation cleanup
const VERSION = 'v42';
const CACHE = `app-${VERSION}`;
self.addEventListener('install', () => {
self.skipWaiting(); // take over when ready
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))
);
await self.clients.claim(); // control existing pages
})());
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (event.request.mode === 'navigate') {
// HTML: network-first, fall back to cache offline
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
return;
}
if (url.pathname.startsWith('/assets/')) {
// hashed immutable assets: cache-first is safe
event.respondWith(
caches.match(event.request).then((hit) =>
hit ?? fetch(event.request).then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(event.request, copy));
return res;
})
)
);
}
});
And let the page cooperate — prompt a reload when the update lands rather than hot-swapping state mid-session.
Verification
Deploy twice in a row and verify each time: the new worker reaches activated and is running, the old caches disappear from Cache Storage after activation, and a returning client loads the new version within one navigation. In DevTools, watch the waiting worker disappear as skipWaiting hands over control. Test the offline path explicitly — network-first HTML with a cache fallback must still serve the shell with the network off. For the reload prompt, confirm a stateful session is never hot-swapped: the page shows the update notice and reloads on user action, not mid-flow. Rule out the related failure: a byte-identical sw.js never triggers an update check, so your build must emit a changed worker file per deploy.