skip to content

You ship a new build with a changed sw.js. The browser downloads and installs it, yet returning users keep getting the old app until they close every tab. Walk through the registration states that produce that, and what has to happen for the new worker to take over.

level: middleimportance: must knowfreq 70%

answer

  1. installing, waiting, active are separate slots
  2. byte comparison of the script triggers it
  3. only one version serves a user at a time
  4. a reload never drops the client count to zero
  5. skipWaiting or close every in-scope tab

basics

~20 s

The new worker installs and then stops in the waiting state because the old worker still controls open pages. It activates only when every client controlled by the old worker is gone — closing all tabs in scope, not reloading them — or when it calls skipWaiting().

solid answer

~50 s

On a navigation the browser re-fetches `sw.js`, sees the bytes differ, and starts the new worker's lifecycle: it installs fine, then becomes *installed* — the **waiting** worker, visible as `registration.waiting`. It stays there because the platform guarantees that only one version of your worker serves a given user at a time: the old one keeps control until every client it controls goes away. A reload is not enough, because the outgoing document overlaps with the incoming navigation, so the old worker never drops to zero clients. Closing every tab and window in scope, or navigating them all off-origin, releases it; the next visit then activates the new worker. The alternatives are to call `self.skipWaiting()` so the new worker activates immediately, or — the pattern most teams ship — detect `registration.waiting`, show an "update available" prompt, and skip waiting only when the user accepts.

code

javascript · 17 lines
javascript
const reg = await navigator.serviceWorker.register('/sw.js');

function onWaiting(worker) {
  console.log('a new version is waiting', worker.state);
  // e.g. show an "update available" prompt here
}

if (reg.waiting && navigator.serviceWorker.controller) onWaiting(reg.waiting);

reg.addEventListener('updatefound', () => {
  const incoming = reg.installing;
  incoming.addEventListener('statechange', () => {
    if (incoming.state === 'installed' && navigator.serviceWorker.controller) {
      onWaiting(incoming);
    }
  });
});

go deeper

for a junior

Know the three slots — installing, waiting, active — and that a new worker sits in waiting while the old one still controls open tabs.

for a middle

Explain the whole sequence: byte comparison on the update check, updatefound, install, then waiting, and why a reload does not release the old worker while closing every in-scope tab does.

for a senior

Diagnose it in production: recognise a stale-worker incident rather than a failed deploy, know how to read registration.waiting from the page, and ship a detection-and-prompt flow instead of instructions to close tabs.

for a principal

Own release semantics for the worker — how fast a fix must reach users, how you measure how many are still on an old version, and what your rollback story is when the bad version is the one holding control.

## The states involved A `ServiceWorkerRegistration` has up to three slots at once: `installing`, `waiting`, and `active`. A worker moves `installing → installed → activating → activated`, and can end as `redundant`. The waiting state is exactly "installed, but something else is still active". ## What happens on your deploy 1. A user navigates to a page in scope. Alongside the navigation the browser performs an **update check**: it re-requests the worker script. 2. It compares the fetched script with the stored one **byte for byte** (imported scripts are compared too). Identical bytes → nothing happens. Different → a new worker is created. 3. The registration fires `updatefound`, and `registration.installing` points at the new worker. Its `install` event runs. 4. Install succeeds, so it becomes *installed*. Because the registration already has an *active* worker with at least one client, the new worker is parked as `registration.waiting`. 5. Nothing further happens until the old worker has **no controlled clients left**. ## Why reloading does not help The intuition "reload releases the tab" is wrong for a specific reason: during a reload the browser starts the new navigation while the old document is still alive, so at no instant does the old worker's client count reach zero. The same applies to navigating within the app. What does release it: - Closing **every** tab and window whose URL is in scope. One forgotten background tab keeps the old worker alive — including a tab in another window, and, on some setups, the same site open in a different profile window. - Navigating every such tab to a URL outside the scope. - Sitting on the page long enough for the browser to discard the tab (unreliable; do not plan around it). This is why the stale-service-worker incident is so persistent: the users most affected are the loyal ones who keep the app open all day. ## Observing the waiting worker ```js const reg = await navigator.serviceWorker.register('/sw.js'); if (reg.waiting) { // A new version is already installed and parked. } reg.addEventListener('updatefound', () => { const incoming = reg.installing; incoming.addEventListener('statechange', () => { if (incoming.state === 'installed' && navigator.serviceWorker.controller) { // Installed while an old worker controls this page → it is now waiting. } }); }); ``` The `navigator.serviceWorker.controller` check matters: on a first-ever install the new worker also reaches `installed`, but there is no old version to displace, so it goes straight on to activate. "Installed **and** we already have a controller" is the signal that this is an update sitting in the waiting room. ## Making it take over There are two levers: - **`self.skipWaiting()`** — called from inside the worker, usually in `install`. It evicts the waiting state: the new worker activates immediately, the old one is terminated, and clients that the old worker controlled become controlled by the new one. Pages then fire `controllerchange`. - **`registration.waiting.postMessage(…)`** from the page, with the worker calling `skipWaiting()` when it receives that signal. This is the same lever, but pulled at a moment *you* choose — typically after the user clicks "Reload to update". The common production pattern is: keep the worker waiting, surface a small toast, and on accept, signal the worker and reload the page once `controllerchange` fires. It gives the user a coherent moment where everything changes at once, instead of swapping the worker under a page that is mid-session. ## What this means when you are debugging - DevTools → Application → Service Workers shows the waiting worker and offers **skipWaiting** and **Update on reload**, which forces the new worker to activate on each reload. Both are development conveniences; they hide the behaviour your users get. - If a fix seems not to reach users, check whether it is sitting in `waiting` rather than assuming the deploy failed. - "Users must close all tabs" is never an acceptable release process; if your only path to shipping a fix is that instruction, your activation policy is the bug.

  • Why is reloading the tab not enough to release the old worker?
    Because the outgoing document is still alive while the new navigation is being handled, so the old worker's controlled-client count never reaches zero — and the waiting worker only activates when it does. Closing every in-scope tab, or navigating them off-origin, is what actually releases it. Reloading feels like it should work, which is why the bug is so commonly misdiagnosed as a failed deploy.
  • How do you tell a genuine update from a first-ever install, given both reach the 'installed' state?
    Check `navigator.serviceWorker.controller`. If it is non-null when the incoming worker reaches `installed`, an older version is already controlling this page, so the new one is waiting and you should prompt. If it is null, this is the first install: the worker will activate on its own and there is nothing to tell the user about.
  • Does the browser ever activate the waiting worker on its own after enough time?
    Not on a timer. The condition is structural — the old worker must lose all its controlled clients. Browsers may eventually discard a background tab, which can incidentally release it, but that is opportunistic and not something to design around. If you need deterministic rollout, use skipWaiting under your own control.

saying these in an interview costs you the question

  • Says reloading the page activates the waiting worker
  • Thinks the browser activates the new worker after a timeout
  • Believes the deploy failed because the fix has not appeared
  • Tells users to close all tabs as a release process
  • Confuses the waiting worker with a failed install

context