A service worker has a lifecycle, and almost all the pain with PWA updates comes from not grasping that a new worker does not replace the old one instantly. It passes through the states install, waiting, activate, and finally control over the page. In install the worker fills the precache. In waiting it waits. In activate it puts the system in order - clearing stale caches. Only after that does it begin to control loads. You cannot skip a transition - you get races.
Registering a worker is not a one-off call but a long-lived binding of an origin-and-scope pair to a specific script. Register a worker at /sw.js with scope /, and you create a record that outlives closing the tab and restarting the browser. On the next visit the browser does not register the worker anew - it checks whether the script has changed.
It checks like this: it compares the worker file and its dependencies against the installed version. Identical - nothing happens. Different by even a byte - the browser treats it as a new version and installs it in parallel. The key moment: having assembled the new version, the browser does not activate it at once but holds it in waiting - until all tabs controlled by the old worker have closed.
This holding looks like a nuisance - the update is ready, after all - but the reason is solid. Open tabs have already loaded the old version's assets - scripts and styles with hashes. Were the new worker to activate instantly, one page would mix incompatible versions: old JavaScript would request a chunk no longer present, and the app would crash. Waiting protects open sessions from such mixing. The price is that an update does not arrive at once; managing that comes later.
The update is managed by distinguishing three things beginners confuse. The page is the tab with your code. The registration is the record of the worker for a scope, with installing, waiting, and active fields. The controller is the active worker that now intercepts the page's requests. The subtlety: navigator.serviceWorker.ready resolves once an active worker exists - but navigator.serviceWorker.controller can be null on the first open. So the first load goes over the network; control appears after a reload or an explicit clients.claim.
The page listens for a new version and decides what to do. The updatefound event fires when a new worker has begun installing; it is tracked via statechange. The moment state === 'installed' with a non-null controller means an update rather than a first install. And controllerchange catches the switch of the controlling worker; on it you hang one careful reload onto the new version.
The worker and the page live in different threads and communicate only by messages. The basic mechanism is postMessage; for a request with a reply, MessageChannel with a pair of ports is convenient. When data must reach all tabs at once, BroadcastChannel helps - a shared channel one writes to and all hear. In Offline Notes this helps when sync in one tab has confirmed a send, and the others change the note's status from pending to synced.
The last rule is scope discipline. Do not register workers with overlapping areas without reason. A request is served by the most specific scope, and on overlap the behavior stops being readable. A typical failure: an old worker was left at /app/sw.js, a new one added at /sw.js - part of the pages are silently served by the old code, while debugging is a puzzle: DevTools shows two workers, and which one answered is not obvious. One origin, one worker at the root: boring, predictable, and debuggable.
const reg = await navigator.serviceWorker.register('/sw.js');
reg.addEventListener('updatefound', () => {
const worker = reg.installing;
worker?.addEventListener('statechange', () => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateAvailable();
}
});
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
// One controlled reload after a deliberate update.
location.reload();
});