This page is part of the PWA Offline Recovery & Service-Worker Resilience section, which covers the layer of failure modes that sit beneath every other offline pattern: if the worker itself never registers, never updates, or never activates correctly, precaching, background sync, and telemetry all inherit stale or absent code.
The problem: a deployed fix that never reaches the browser #
You ship a critical bug fix. The build pipeline emits a new sw.js, the CDN serves it, and your dashboards show the deploy succeeded. Users keep filing the same bug report. Eventually you find the cause: the browser fetched the new worker file, installed it, and then parked it in the waiting state — because the previous worker is still controlling every open tab, and by the service worker specification a waiting worker does not take over while a controlled client exists. The user has to close every tab of your app, not just refresh, before the new code takes effect. Most users never do that.
This is the single most common source of “it works locally but production users are stuck on old code” tickets in service-worker-backed applications. Compounding it: navigator.serviceWorker.register() can fail silently in some private-browsing contexts, returning a rejected promise that nothing observes if the call site has no .catch. A scope mismatch between where the worker file is served and which paths it’s supposed to control produces a registration that “succeeds” but never intercepts a single fetch. And a naive update check — one that doesn’t distinguish first install from a genuine update — can prompt a first-time visitor to “reload for the latest version,” which is both confusing and wrong.
User-visible consequence: users silently running a stale, possibly broken bundle for days; a support queue full of “already tried refreshing” tickets; and, when a fix does land, either no prompt at all or a jarring unprompted reload mid-interaction.
The fix requires modeling the install → waiting → activate lifecycle explicitly on the client, rather than assuming the browser will “just update.” The rest of this guide builds that model as a reusable registration module, then extends it into the service worker itself.
Prerequisites #
Core implementation: a robust client-side registration module #
The module below registers the worker with an explicit scope, watches for updatefound, and distinguishes “this is the very first install” from “this is a real update the user should be prompted about” by checking whether navigator.serviceWorker.controller already exists. Accepting the prompt posts a typed message telling the waiting worker to skip waiting; the module reloads exactly once, guarded by a module-level flag, when controllerchange fires.
// sw-register.ts — runs in the window context, not inside the worker
type UpdateAcceptedCallback = () => void;
export interface RegisterOptions {
scriptUrl?: string;
scope?: string;
/** Invoked when an update is installed and ready; call the returned
* function to accept the update (or ignore it to defer). */
onUpdateAvailable?: (accept: UpdateAcceptedCallback) => void;
}
// Guards against firing more than one reload if controllerchange fires twice
let hasReloaded = false;
export async function registerServiceWorker(
options: RegisterOptions = {}
): Promise<ServiceWorkerRegistration | null> {
const { scriptUrl = '/sw.js', scope = '/', onUpdateAvailable } = options;
// Feature-detect first: some private-browsing modes expose no
// navigator.serviceWorker at all, and calling into it throws.
if (!('serviceWorker' in navigator)) {
console.warn('[sw] Service workers are not supported in this context.');
return null;
}
let registration: ServiceWorkerRegistration;
try {
registration = await navigator.serviceWorker.register(scriptUrl, { scope });
} catch (err) {
// register() rejects rather than throwing synchronously — an
// unguarded call here fails completely silently.
console.error('[sw] Registration failed:', err);
return null;
}
// A worker may already be waiting from a previous page load that
// never reloaded — surface it immediately rather than waiting for
// the next updatefound event, which won't fire again for it.
if (registration.waiting && navigator.serviceWorker.controller) {
promptForUpdate(registration.waiting, onUpdateAvailable);
}
registration.addEventListener('updatefound', () => {
const installingWorker = registration.installing;
if (!installingWorker) return;
installingWorker.addEventListener('statechange', () => {
if (installingWorker.state !== 'installed') return;
// No controller means this is the FIRST install on this origin —
// there is nothing to "update" and nothing to prompt about.
if (!navigator.serviceWorker.controller) {
console.info('[sw] Content is now cached for offline use.');
return;
}
// A controller already exists, so an installed-but-waiting worker
// here is a genuine update: the old worker is still serving traffic.
promptForUpdate(installingWorker, onUpdateAvailable);
});
});
// Fires once the new worker calls skipWaiting and becomes the
// controller. Reload exactly once — never on a second event.
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (hasReloaded) return;
hasReloaded = true;
window.location.reload();
});
return registration;
}
function promptForUpdate(
worker: ServiceWorker,
onUpdateAvailable?: (accept: UpdateAcceptedCallback) => void
): void {
const accept: UpdateAcceptedCallback = () => {
// The worker is asked, not forced, to skip waiting — see the
// advanced variant below for the guarded self.skipWaiting() side.
worker.postMessage({ type: 'SKIP_WAITING' });
};
if (onUpdateAvailable) {
onUpdateAvailable(accept);
} else {
// Fallback UI when the caller doesn't supply its own prompt
if (window.confirm('A new version is available. Reload now?')) {
accept();
}
}
}
Call site in your app’s entry point:
registerServiceWorker({
scriptUrl: '/sw.js',
scope: '/',
onUpdateAvailable: (accept) => {
// Wire this to your toast/banner component instead of confirm()
showUpdateBanner({ onAccept: accept });
},
});
Architecture note #
The lifecycle the browser enforces is: installing → installed (which is the waiting state whenever an existing controller is present) → activating → activated, with redundant reachable from any state if the worker is superseded or its install/activate handler throws. Crucially, an installed worker does not auto-promote to activating while any client — any open tab, including background tabs — is still controlled by the previous worker. This is a deliberate safety guarantee: it prevents a page mid-interaction from having its network-intercepting code swapped out from under it.
Two APIs break that guarantee on purpose, and they solve different halves of the problem. self.skipWaiting(), called from inside the worker, lets the waiting worker move to activating immediately instead of waiting for clients to close — this is what makes the update apply now rather than on next full reload. clients.claim(), called from inside the activate handler, takes control of clients that are already open but were still being served by the previous worker (or by no worker at all, on first load) — without it, a newly activated worker won’t intercept fetches from tabs opened before it existed until they navigate again. Calling skipWaiting() without clients.claim() leaves open tabs on the old worker even after the new one activates; calling clients.claim() without ever calling skipWaiting() means the new worker still waits indefinitely for tabs to close before it can claim anything. Registration and lifecycle management belong to the client-side module above; the worker-side half of this handshake is the advanced variant further down.
Edge cases and gotchas #
| Failure mode | Symptom | Mitigation |
|---|---|---|
| Double reload loop | Page reloads twice in a row, sometimes losing unsaved form state | Guard the controllerchange listener with a module-level boolean set on first reload |
| Scope mismatch | Registration resolves successfully but the worker never intercepts fetches for certain paths | Serve sw.js from the highest path you need to control, or set the Service-Worker-Allowed response header to widen scope |
| Stale cache versioning | Users see old assets even after activation completes | Key cache names to a build-time version constant and delete non-matching caches in activate |
| Hard-refresh bypass | DevTools “Update on reload” or a user’s hard refresh (Shift+Reload) skips the worker entirely | Expected browser behavior for debugging; do not rely on it as your update mechanism in production |
| Redundant/duplicate workers | Two worker instances appear to run simultaneously across tabs | Confirm the scope and script URL are byte-identical across every registration call site in the app |
| Silent registration failure | register() resolves to a rejected promise nobody observes |
Always await inside a try/catch, and feature-detect 'serviceWorker' in navigator first |
Advanced variant: SW-side lifecycle handlers and cache cleanup #
The client-side module only asks a waiting worker to skip waiting; the worker itself decides whether and how. The version below guards skipWaiting() behind the message contract established above, claims clients on activation, and deletes any cache whose key doesn’t match the current version constant.
// sw.ts — runs inside the service worker global scope
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const CACHE_VERSION = 'v3-2026-07-06';
const CACHE_NAME = `app-shell-${CACHE_VERSION}`;
const PRECACHE_URLS = ['/', '/offline.html', '/styles/app.css'];
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
});
self.addEventListener('message', (event: ExtendableMessageEvent) => {
// Only ever skip waiting in response to an explicit, typed request
// from the client — never unconditionally at install time, which
// would defeat the safety guarantee open tabs rely on.
if (event.data?.type === 'SKIP_WAITING') {
void self.skipWaiting();
}
});
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
// Drop every cache that doesn't match the current version —
// this is what actually frees stale assets, not activation alone.
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key.startsWith('app-shell-') && key !== CACHE_NAME)
.map((key) => caches.delete(key))
);
// Take control of clients opened before this worker activated,
// so they start getting intercepted fetches without a navigation.
await self.clients.claim();
})()
);
});
Note the guard on the message listener: skipWaiting() only ever runs in direct response to the client’s typed SKIP_WAITING command, never automatically inside install. Calling it unconditionally at install time is a common shortcut that defeats the entire point of the waiting state — it forces every open tab onto new code mid-session, which is exactly the behavior the lifecycle exists to prevent. Reserve that shortcut for assets where an old tab breaking outright is acceptable; for most applications, a controlled prompt-and-reload is the safer default.
Testing and CI/CD validation #
Manual verification starts in DevTools: open Application → Service Workers, enable Update on reload while iterating, and use the skipWaiting link that appears next to a waiting worker to confirm your prompt-and-reload path fires correctly without that shortcut. Confirm in the same panel that only one worker is ever listed as active per scope — a second entry under a different scope is the signature of the scope-mismatch failure mode above.
For CI, drive the full registration-to-activation handshake with Playwright, asserting controllerchange fires exactly once:
import { test, expect } from '@playwright/test';
test('accepting the update prompt reloads exactly once', async ({ page }) => {
await page.goto('/');
await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
let reloadCount = 0;
page.on('framenavigated', () => { reloadCount += 1; });
// Deploy a "new" worker by swapping the served script version and
// forcing an update check.
await page.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration();
await registration?.update();
});
// Wait for the update banner your app renders via onUpdateAvailable
await page.getByRole('button', { name: 'Reload now' }).click();
await page.waitForFunction(
() => navigator.serviceWorker.controller?.scriptURL.includes('v-next') ?? false
);
expect(reloadCount).toBeLessThanOrEqual(1);
});
Assert three things in every run: that registration.waiting is non-null immediately after the simulated deploy, that the controller’s scriptURL reflects the new worker only after the prompt is accepted (never before), and that the frame navigates at most once during the whole sequence — a second navigation event is the double-reload bug surfacing in CI before it reaches production.
Once the happy path here is solid, the child guide Recovering from a Stuck Service Worker with skipWaiting and clients.claim walks through the narrower emergency case: a worker that’s already stuck in production, with users mid-session, and no deploy window to wait for a graceful update prompt.
Registration and activation are only half of a resilient offline setup. Once a worker reliably controls the page, what it serves from cache matters just as much — see App Shell Precache & Offline Fallback Routing for how to structure the precache list this guide’s install handler references. And because lifecycle bugs are notoriously hard to reproduce from user reports alone, pairing this registration module with Service Worker Error Telemetry & Debugging gives you visibility into which registration or activation step actually failed in the field, rather than guessing from a support ticket.
Frequently asked questions #
Why does a new service worker stay in the waiting state instead of activating immediately?
By design, a new worker waits as long as any client controlled by the old worker remains open, so an in-progress session is never yanked onto different code mid-interaction. It only activates once every controlled tab closes, or once something explicitly calls skipWaiting.
What is the difference between skipWaiting and clients.claim?
skipWaiting lets a waiting worker move straight to activating without waiting for open tabs to close. clients.claim takes control of already-open, uncontrolled clients during activation. You typically need both: skipWaiting to progress the lifecycle, and clients.claim so the newly active worker starts intercepting fetches on tabs that were opened before it existed.
Why does registration silently fail in some browsers or private windows?
Some private-browsing modes disable persistent storage APIs the service worker depends on, and register() rejects with a DOMException rather than throwing synchronously, so an unguarded call produces no visible error. Always call register() inside an async function with an explicit catch and feature-detect navigator.serviceWorker before calling it.
How do I avoid an infinite reload loop when forcing an update?
Track a module-level boolean before calling reload inside the controllerchange handler, and only reload if it hasn’t already fired. Without that guard, any second controllerchange event — which can happen if skipWaiting runs more than once — reloads the page again, and if the update-prompt logic re-triggers immediately you get a reload storm.
Why did my service worker register but never seem to control the page?
The most common cause is a scope mismatch: a worker registered from /assets/sw.js can only control pages under /assets/ unless the Service-Worker-Allowed header widens it. The second most common cause is that the very first registration on a page load never has a controller until the next navigation, which is expected and not a bug.
Related #
- PWA Offline Recovery & Service-Worker Resilience — parent section
- Recovering from a Stuck Service Worker with skipWaiting and clients.claim — the emergency, already-stuck-in-production variant of this guide
- App Shell Precache & Offline Fallback Routing — what the install handler’s precache list should actually contain
- Service Worker Error Telemetry & Debugging — capturing which lifecycle step failed in production