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 #

  1. Register without forcing activation. registerServiceWorker() calls navigator.serviceWorker.register('/sw.js') and checks registration.waiting immediately — this covers the case where the tab loaded after a new worker had already finished installing and was already parked in waiting.

  2. Watch for a worker installing mid-session. The updatefound listener attaches a statechange listener to registration.installing. When that worker reaches installed while registration.waiting is set, an update is available and onUpdateAvailable() fires — typically rendering an “Update available” banner.

  3. Do not skip waiting inside install. The service worker’s install handler only precaches assets; it deliberately never calls self.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.

  4. Message-driven skipWaiting(). The worker’s message listener checks for { type: 'SKIP_WAITING' } and only then calls self.skipWaiting(). This line runs applyUpdate(registration), which posts that message to registration.waiting — invoked from the update banner’s “Reload now” button, not automatically.

  5. clients.claim() inside waitUntil. Once the worker enters activate, 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.

  6. Guarded single reload. controllerchange fires once per tab when control transfers. The reloadTriggered flag — a plain module-level boolean, not component state — ensures window.location.reload() runs exactly once per page, even if controllerchange were 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 #

  1. 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”.

  2. Trigger the prompt. Confirm the update banner renders when onUpdateAvailable() fires, then click the accept action and confirm registration.waiting.postMessage is called — check the Network/Console for no errors during the transition.

  3. 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.

  4. 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.

  5. Multi-tab check. Open the app in two tabs, deploy, and accept the update in one tab. Confirm the second tab also receives controllerchange and 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.