This page assumes the lifecycle groundwork covered in Service Worker Lifecycle & Registration Recovery: registering a worker, handling install/activate, and recovering from a failed registration.
The exact failure this page solves #
A team ships a deploy. The new service worker installs successfully and sits in the waiting state — but it never activates, because the precache manifest rule for service workers is that a waiting worker only takes over once every tab controlled by the old worker has fully closed. Users rarely close every tab of a long-lived app; they leave it pinned, backgrounded, or open in a second monitor. The result: production serves a stale bundle indefinitely, bug fixes never reach real users, and support tickets pile up referencing a version the team thought had already shipped days ago.
The fix is a coordinated three-part handshake. The service worker listens for an explicit { type: 'SKIP_WAITING' } message and calls self.skipWaiting() only then — never unconditionally. Its activate handler calls self.clients.claim() inside event.waitUntil() so it can take over already-open tabs the moment it activates. The page itself listens for the controllerchange event fired on navigator.serviceWorker and reloads exactly once, guarded by a flag, only after the user has agreed to update. This keeps control in the user’s hands while eliminating the “wait forever” failure mode entirely.
Zero-to-working implementation #
Two files cooperate: the service worker (sw.js) and the page-side controller that registers it and drives the update prompt.
// sw.js — service worker file
const CACHE_NAME = 'app-shell-v7'; // bump on every deploy
self.addEventListener('install', (event) => {
// Do NOT call self.skipWaiting() here — wait for explicit consent
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(['/', '/offline.html']))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
// Drop caches from older deploys before taking control
const keys = await caches.keys();
await Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
);
// Take control of already-open, uncontrolled tabs right away
await self.clients.claim();
})()
);
});
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting(); // moves this worker from waiting -> activating
}
});
// updateController.ts — page-side registration and reload guard
let reloadTriggered = false; // module-level guard: survives across event firings
export function registerServiceWorker(onUpdateAvailable: () => void): void {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.register('/sw.js').then((registration) => {
// Case 1: a worker is already waiting when we register
if (registration.waiting) onUpdateAvailable();
// Case 2: a new worker starts installing after we're already loaded
registration.addEventListener('updatefound', () => {
const installing = registration.installing;
if (!installing) return;
installing.addEventListener('statechange', () => {
if (installing.state === 'installed' && registration.waiting) {
onUpdateAvailable();
}
});
});
});
// Fires once the new worker calls clients.claim() and takes control
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloadTriggered) return; // guard against a reload loop
reloadTriggered = true;
window.location.reload();
});
}
export function applyUpdate(registration: ServiceWorkerRegistration): void {
registration.waiting?.postMessage({ type: 'SKIP_WAITING' });
}
Step-by-step explanation #
-
Register without forcing activation.
registerServiceWorker()callsnavigator.serviceWorker.register('/sw.js')and checksregistration.waitingimmediately — this covers the case where the tab loaded after a new worker had already finished installing and was already parked inwaiting. -
Watch for a worker installing mid-session. The
updatefoundlistener attaches astatechangelistener toregistration.installing. When that worker reachesinstalledwhileregistration.waitingis set, an update is available andonUpdateAvailable()fires — typically rendering an “Update available” banner. -
Do not skip waiting inside
install. The service worker’sinstallhandler only precaches assets; it deliberately never callsself.skipWaiting(). Calling it unconditionally here is the root cause of mismatched-asset bugs, because it activates the worker while old tabs are still executing against the previous bundle’s expectations. -
Message-driven
skipWaiting(). The worker’smessagelistener checks for{ type: 'SKIP_WAITING' }and only then callsself.skipWaiting(). This line runsapplyUpdate(registration), which posts that message toregistration.waiting— invoked from the update banner’s “Reload now” button, not automatically. -
clients.claim()insidewaitUntil. Once the worker entersactivate,event.waitUntil(self.clients.claim())(wrapped in the async IIFE above alongside cache cleanup) ensures the activation event does not resolve until every open tab has been claimed, so subsequent fetches go through the new worker. -
Guarded single reload.
controllerchangefires once per tab when control transfers. ThereloadTriggeredflag — a plain module-level boolean, not component state — ensureswindow.location.reload()runs exactly once per page, even ifcontrollerchangewere to fire more than once due to a rapid second deploy.
Edge cases #
| Scenario | Symptom | Mitigation |
|---|---|---|
Unconditional skipWaiting() in install |
Live tab suddenly fetches assets the new worker no longer caches, throwing a 404 or a decode error mid-session | Only call skipWaiting() from the message handler, gated on explicit user consent via the update prompt |
| Missing reload guard | controllerchange fires, page reloads, re-registers, sees registration.waiting again in a race, reloads again — infinite loop |
Set a module-level reloadTriggered flag before calling reload(); never rely on component state that resets on reload |
In-flight fetch during the handoff |
A request started under the old worker resolves inconsistently against new-version cache entries | Keep CACHE_NAME versioned per deploy so the old worker’s in-flight requests still resolve against its own cache until it terminates naturally |
| Multiple tabs open at once | Each tab fires its own controllerchange independently |
This is expected and safe — the guard is per-page, so each tab reloads once on its own; do not attempt to coordinate reloads across tabs with BroadcastChannel unless you specifically want to batch them |
Verification steps #
-
DevTools → Application → Service Workers. Load the page once, then deploy a change and reload the DevTools panel (not the page). Confirm a new worker appears in the “waiting to activate” state and the current worker stays “activated and is running”.
-
Trigger the prompt. Confirm the update banner renders when
onUpdateAvailable()fires, then click the accept action and confirmregistration.waiting.postMessageis called — check the Network/Console for no errors during the transition. -
Confirm single reload. Watch the page reload exactly once after accepting; open the console and add a temporary
console.log('controllerchange fired')inside the listener to confirm it logs once per tab, not repeatedly. -
Deploy twice in a row. Ship a second change immediately after the first activates. Confirm the flow repeats cleanly — a new worker installs, waits, and the same banner-and-reload sequence completes without requiring a hard refresh or clearing site data.
-
Multi-tab check. Open the app in two tabs, deploy, and accept the update in one tab. Confirm the second tab also receives
controllerchangeand reloads independently within the same activation cycle.
Frequently asked questions #
Why not just call self.skipWaiting() unconditionally at the top of the service worker file?
Because it activates the new worker immediately, even while old tabs are still running JavaScript that expects the previous asset versions. A page can end up requesting a chunk the new worker no longer caches, producing a runtime error mid-session. Gating skipWaiting() behind an explicit user-triggered message avoids that mismatch entirely.
Will clients.claim() affect a page that has an in-flight fetch request?
In-flight requests already dispatched to the old worker’s fetch handler continue to be served by that instance; claim() only changes which worker handles requests initiated after control transfers. New requests issued a moment later go to the new worker, which is why cached routes should stay backward-compatible across a single deploy.
What happens if the user has multiple tabs of the app open?
clients.claim() takes control of every open tab in that origin’s scope at once, so each one fires its own controllerchange event. Because the reload guard is scoped per-page rather than shared across tabs, each tab reloads independently and exactly once, instead of triggering a cascade of repeated reloads.
Related #
- Service Worker Lifecycle & Registration Recovery — parent guide covering registration failures and lifecycle event handling
- PWA Offline Recovery & Service-Worker Resilience — the broader section on offline durability and worker resilience patterns
- App Shell Precache & Offline Fallback Routing — precache strategy that this worker’s
installhandler depends on