skip to content

Service Worker Lifecycle

You will learn the install/activate/fetch lifecycle and the update dance that leaves a new worker waiting while the old one still controls open tabs. Interviewers ask because the stale-service-worker bug is the classic PWA production incident.

on this pageshow

questions

6

A page calls navigator.serviceWorker.register('/sw.js') on a visitor's first visit, and that worker has a fetch handler. Does the worker intercept the requests of the page load that registered it? Explain what decides whether a document is controlled.

level: juniorimportance: must knowfreq 65%

answer

  1. controller decided at navigation time
  2. first visit has no active worker
  3. registering does not intercept retroactively
  4. clients.claim() adopts uncontrolled pages

basics

~20 s

No. A document's controller is chosen when it is navigated to, and on a first visit no worker is active yet, so navigator.serviceWorker.controller is null and every request on that load goes straight to the network.

solid answer

~40 s

No. `register()` only starts the lifecycle: the browser downloads the script, runs `install`, then `activate`, and by the time the worker is activated the page has already been navigated and its resources fetched. A document's controller is fixed at navigation time, so a page that was loaded with no active worker stays uncontrolled for its whole life — `navigator.serviceWorker.controller` is `null` and the `fetch` handler never sees its requests. The next navigation inside the registration's scope is controlled, which is why the common phrasing is "the service worker takes effect on the second load". If you want the registering page itself to become controlled, the worker must call `self.clients.claim()` in its `activate` handler; the page then fires `controllerchange`, and requests it makes *after* that point go through the worker.

go deeper

for a junior

Be able to say plainly that the worker takes effect from the next navigation, and that navigator.serviceWorker.controller is null on the load that registered it.

for a middle

Explain the ordering: register triggers download, install, then activate, all after the page has already fetched its resources, and that a document's controller is fixed at navigation time.

for a senior

Show you can diagnose half-controlled sessions in production — knowing that clients.claim() only affects requests made after the claim, and that a hard reload loads the page uncontrolled, keeps you from misreading bug reports.

for a principal

Own the decision of whether to claim at all: claiming makes the first session consistent but means a page loaded from the network is suddenly served by a worker, so decide it alongside your activation and versioning policy.

## The two things people conflate A service worker registration has a **lifecycle** (installing → installed → activating → activated → redundant) and separately each open document has a **controller** — the worker, if any, that gets `fetch` events for that document's requests. Registering a worker advances the lifecycle. It does not retroactively hand an already-loaded document a controller. ## What register() actually does ```js const reg = await navigator.serviceWorker.register('/sw.js'); ``` The browser resolves the script URL, checks it is same-origin and in a secure context (HTTPS, or `localhost` for development), downloads it, and if it is new it creates a worker in the *installing* state. The `install` event fires; when it finishes the worker becomes *installed*. If no other worker is active for that registration, it moves straight on to *activating*, fires `activate`, and becomes *activated*. All of that is asynchronous and typically completes after the page has already finished loading its HTML, CSS and scripts from the network. ## The controller is chosen at navigation time When the browser navigates to a URL, it looks for a registration whose scope matches that URL and which already has an **active** worker. If it finds one, the new document is created *controlled* by that worker, and from then on the worker receives a `fetch` event for the document's subresource requests and for later `fetch()`/`XMLHttpRequest` calls. If there is no active worker at navigation time, the document is created **uncontrolled**, and that is permanent for the document unless the worker claims it. This is why the very first visit — the one that installs the worker — is never intercepted. You can observe it directly: ```js navigator.serviceWorker.register('/sw.js').then((reg) => { console.log(navigator.serviceWorker.controller); // null on the first visit console.log(reg.active); // may be non-null moments later }); ``` Note the asymmetry: `reg.active` can be a worker while `navigator.serviceWorker.controller` is still `null`. The registration has an active worker; *this document* simply is not one of its clients. ## clients.claim() The escape hatch is `self.clients.claim()`, called from the worker's `activate` handler: ```js self.addEventListener('activate', (event) => { event.waitUntil(self.clients.claim()); }); ``` This makes the newly activated worker take control of in-scope clients that currently have no controller. Each such page fires `controllerchange` on `navigator.serviceWorker`, and `navigator.serviceWorker.controller` becomes non-null. Be honest about what this buys you: the page's HTML, CSS and initial scripts were already fetched from the network before the claim happened, so claiming does not retroactively serve them from the worker. It only affects requests the page makes *after* it is claimed — lazily loaded chunks, API calls, images added later. Its real value is making the first session behave consistently instead of half-controlled. ## How to tell what state you are in - `navigator.serviceWorker.controller` — `null` means this document is uncontrolled. - `navigator.serviceWorker.ready` — resolves with a registration that has an active worker. It does **not** promise that this document is controlled; a first-load page can have `ready` resolve while `controller` is still `null`. - `navigator.serviceWorker.addEventListener('controllerchange', …)` — fires when the document's controller changes, e.g. after a claim or after a new worker skips waiting. ## Traps worth knowing - **Hard reload bypasses the worker.** A shift-reload (or "Empty cache and hard reload") loads the page uncontrolled on purpose, which is why a bug "disappears" for the developer testing it that way. - **Secure context required.** Registration only works over HTTPS or on `localhost`; on plain HTTP over a network the promise rejects. - **Scope matters.** A page outside the registration's scope is never controlled, no matter how many times it registers the worker. - **Registering repeatedly is cheap.** Calling `register()` with the same URL on every page load is the normal pattern; if the byte content is unchanged the browser keeps the existing registration.

  • Can you make the very first load benefit from the worker without a second navigation?
    Only partially. `self.clients.claim()` in `activate` takes control of the already-open, uncontrolled page and fires `controllerchange`, but the document's HTML and initial subresources were fetched before that moment. Only later requests — lazy chunks, API calls, images — go through the worker. If you genuinely need the whole first load handled, you have to reload after `controllerchange`, which costs the user a visible reload.
  • Does navigator.serviceWorker.ready resolving mean the current page is controlled?
    No. `ready` resolves with a registration that has an *active* worker for the page's scope. That is a property of the registration, not of this document. On a first visit `ready` can resolve while `navigator.serviceWorker.controller` is still `null`. To know whether this document is controlled, check `controller` or listen for `controllerchange`.
  • Why does the bug you are chasing disappear when you hard-reload the page?
    A hard reload (shift-reload) deliberately loads the document without a controller and bypasses the worker entirely, so responses come from the network. That makes it a good confirmation that the worker is the cause, but a bad way to test a fix — use a normal reload, or DevTools' Application panel, to exercise the controlled path.

saying these in an interview costs you the question

  • Thinks register() immediately intercepts the current page's requests
  • Believes the fetch handler runs during the install event
  • Says any reload switches the page's controller
  • Claims service workers work over plain HTTP on any host
  • Confuses navigator.serviceWorker.ready with the page being controlled

context

open as a page

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%

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().

open as a page

Inside a service worker, what does event.waitUntil(promise) do in the install and activate events, and what goes wrong if you start async work without it?

level: middleimportance: must knowfreq 60%

basics

~20 s

waitUntil() extends the lifetime of a service worker event: it keeps the worker alive until the promise settles and holds the lifecycle at that stage. Without it the browser may treat install or activate as finished — and terminate the worker — while your async work is still running.

open as a page

A service worker script is served from /static/js/sw.js and the page calls navigator.serviceWorker.register('/static/js/sw.js', { scope: '/' }). Why does that registration fail, and what would make it work?

level: middleimportance: should knowfreq 50%

basics

~20 s

A worker may only control URLs at or below the directory its script is served from, so a script at /static/js/ cannot claim the scope /. The register() promise rejects with a SecurityError unless the script's HTTP response carries the Service-Worker-Allowed header naming the wider scope.

open as a page

A broken service worker is live and returning visitors keep getting a broken app even after you redeploy. How does the browser decide it has a new worker script, and how do you actually kill a bad worker for users who already carry it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

The browser re-fetches the worker script on navigations in scope and compares it byte for byte with the stored copy, so an unchanged or HTTP-cached script means no update. To kill a bad worker, ship a replacement sw.js that calls skipWaiting and clients.claim, then unregisters itself and reloads its clients.

open as a page

For a single-page app that lazily loads content-hashed JavaScript chunks, would you have the service worker call self.skipWaiting() on every install? Lay out the tradeoff against leaving the new worker waiting and prompting the user to reload.

level: principalimportance: should knowfreq 35%

basics

~20 s

Usually not. skipWaiting() activates the new worker under pages that were loaded from the previous build, so a running page can ask for chunk URLs the new worker no longer knows about. For a chunked SPA, prefer leaving it waiting and prompting the user to reload.

open as a page