Web Platform · Progressive Web

Service Worker Lifecycle and the Registration Scope

How a service worker moves from registration through installation and activation, why a new version may wait indefinitely, and what the scope URL decides about the requests it can intercept.

A service worker is a script that the browser runs outside the page, in a worker global scope with no DOM, on behalf of an origin. It is not loaded because a page asked for it at that moment; it is registered, installed once, and then started and stopped by the browser as events arrive. That difference from an ordinary script is the source of most of the confusion around it: the lifecycle has states that outlive any tab, and a new version of the script can sit in a waiting state for as long as a single old tab remains open.

Registration persists intent, not execution

Registration is a page-side operation. The page calls navigator.serviceWorker.register() with a script URL and an optional scope, and the browser records a registration record for that origin and scope. From that point the registration exists independently of the page that created it; closing every tab does not unregister the worker, while clearing the origin’s site data through the browser does.

// Page context. The promise resolves once the script is fetched and parsed,
// not when it is activated.
navigator.serviceWorker.register('/sw.js', { scope: '/' })
  .then((registration) => {
    console.log(registration.scope);      // absolute URL, trailing slash
    console.log(registration.active?.state); // 'activated', or absent on first install
  })
  .catch((error) => {
    // SecurityError: script not same-origin, or scope outside the script's path
    console.error(error.name);
  });

Two constraints apply at registration time. The script must be served from the same origin as the page, and the scope must be at or below the script’s own directory unless the response carries a Service-Worker-Allowed header naming a wider scope. A worker at /assets/sw.js cannot claim / by passing scope: '/'; the browser rejects the registration unless the server sends Service-Worker-Allowed: / with the script. This is a deliberate containment rule: the ability to intercept requests is consequential enough that a script should not be able to widen its own reach purely from JavaScript.

The default scope is the script’s directory. /sw.js defaults to /, /app/sw.js defaults to /app/, and registration.scope reports the resolved absolute URL, which ends in a slash when the scope names a directory.

What the scope decides

Scope is a URL prefix test used to decide which navigations a worker may claim. A worker whose scope is /app/ is offered the navigation requests for /app/, /app/settings, and /app/admin/users, and is not offered a navigation to /other/page; that request goes to the network or to a worker whose scope covers it. One origin can hold several registrations with nested scopes, and a navigation is offered to the registration with the longest matching scope, so a worker at /app/admin/ wins over one at /app/ for URLs below it.

Once a document is controlled, the worker receives that document’s subresource requests, and those are not filtered by scope. An image from a CDN or a stylesheet under /other/ that a controlled /app/ page loads still produces a fetch event on the /app/ worker, because control follows the client rather than the requested URL. Navigations are the exception, since no client exists before the document does; the URL alone decides.

Scope does not restrict the URLs a worker may fetch. A handler can request any origin, subject to normal CORS rules; scope decides which requests are delivered to the event. This asymmetry matters for a site whose assets live on a CDN: the worker is scoped over the document paths and still sees the CDN requests that controlled pages make, so a handler can cache them under their full cross-origin URL without any change to the worker’s scope.

Install, wait, activate

The lifecycle has three transitions after the script is fetched, and each exists to prevent a document from talking to two different versions of the same worker mid-session.

Installation runs the script’s top-level code and dispatches install. This is the only phase where a new worker can populate caches without disturbing the current one, and it is where a precache list is normally fetched. event.waitUntil() holds the install phase open; if the promise rejects, installation fails, the worker is discarded, and the previous registration (if any) keeps serving. Nothing about a failed install leaves a half-installed worker behind.

Waiting is the state a successfully installed worker enters when an older worker still controls at least one client. It waits because the alternative — swapping handlers underneath a page that was built against the old one — produces inconsistencies that are hard to reproduce: a document loaded from cache A while its subresources come from cache B. The worker leaves the waiting state when the last client it does not control goes away, when skipWaiting() is called, or when the browser decides to proceed. A page can detect the situation through registration.waiting and offer the user a reload, but that choice is a product decision rather than a protocol one.

Activation dispatches activate, which is where the previous version’s caches are deleted and where clients.claim() may be called to start controlling pages that loaded before this worker existed. Activation does not automatically take over existing clients: a page loaded without a controller stays uncontrolled until it is reloaded, even though registration.active now points at the new worker.

The three states are observable on the registration object as installing, waiting, and active. Only one worker per registration can be active; waiting and active can coexist.

A worker with no lifetime of its own

After activation the worker is idle-able. The browser may terminate it when no event is pending, and starts it again when the next event arrives — a navigation, a fetch, a push, or a message. Engines use an idle timeout in the range of tens of seconds, so any state stored only in a module-level variable is lost between events. State that must survive belongs in IndexedDB or in the Cache API; the worker’s global scope is a cache of the last activation, not a durable store.

For the same reason, top-level code runs again on every restart. It must be idempotent and cheap. Registering a fetch listener at top level is required for the very first event to be delivered after a restart, because the browser determines whether a worker handles fetches by whether the script added a listener during its initial evaluation. A listener attached later, inside an asynchronous callback, arrives too late for requests that triggered the start.

Communication with pages is asynchronous and one-directional per call: clients.matchAll() returns the controlled clients, and client.postMessage() sends a structured-cloneable value that surfaces as a message event. There is no return value and no direct access to the page’s DOM.

Restarts are also why update checks look sporadic. The browser checks the script for changes on navigation to a scoped page, on a functional event, and when registration.update() is called explicitly; the first two are skipped when the registration was checked within roughly the last day. The check compares bytes, not versions: a byte-identical response leaves the registration alone even if the server sent a later Last-Modified. This is where the registration’s updateViaCache setting matters, since it decides whether the HTTP cache may answer the script request. That interaction, and the mixed-version problem it creates, is examined in Service Worker Update Semantics and skipWaiting.

Failure modes worth designing around

Three failure modes recur often enough to design around. The first is a worker that caches the app shell during install but never deletes old cache names during activate, so storage grows with each deployment until the origin hits quota. The second is calling skipWaiting() unconditionally, which activates a new handler while old documents are still running; the old documents then load subresources from the new caches, and lazy-loaded chunks with hashed names disappear. The third is assuming the worker is running. Any logic that depends on an in-memory flag — a “sync in progress” boolean, a connection state — silently resets whenever the browser stops the worker.

Registration also fails in ways that are easy to miss: a script served with a MIME type other than JavaScript, a redirect on the script URL, or an origin whose certificate is invalid. Each is reported as a rejected registration promise, and each prevents the fetch handler from existing at all. The interception behaviour described in Fetch Event Interception and Response Construction is therefore conditional on a registration that has reached active; until then, every request goes to the network exactly as it would on a site with no worker.

References

  1. Service Workers Nightly — W3C Web Applications Working Group
  2. Service Worker API — MDN Web Docs