---
title: 'Service Workers'
source: 'https://academia.sh/en/courses/browser-platform/service-workers'
course: 'The Browser and the Web Platform'
language: en
updated: '2026-08-17T18:09:12+00:00'
license: 'CC BY-SA 4.0'
---

# Service Workers

A layer that lives independent of the page and can intervene to answer requests; the scope rule, the install-wait-activate cycle, the request-handling contract, and how a bad deployment is rolled back.

Everything built up to here holds once the page has loaded. Behind all of it sits a single
assumption: the component's code, template, and data came from the network. When the
connection weakens, this assumption fails, and the page, however well encapsulated, lands on
an error screen. Where the North Slope Measurement Station page is used does not favor this:
the person taking the measurement is usually at the edge of coverage.

This lesson builds a layer that can see the page's requests before they reach the network and
lives independently of the page: the **service worker**.

## A Context Independent of the Page

A service worker is a kind of the worker thread introduced in the Web Workers lesson, and it
carries all of that thread's restrictions: no access to the document tree, cannot see the
page's variables, talks to the page only by passing messages. It adds its own restrictions on
top.

**It is not bound to a page.** The registration stays in place even if the registering page
closes; when another page of the same origin opens, the same worker takes over. This makes it
not a page component but a service belonging to the origin.

**It does not run continuously.** The browser wakes the worker when an event arrives and
terminates it once the work is done. The time in between is not guaranteed. A binding rule
follows from this: **variables in the worker's global scope cannot hold persistent state.**
They can be lost between two events. Anything that needs to persist is written to the storage
layer.

**It runs only in a secure context.** Registration requires an **origin** served over HTTPS;
the loopback address is kept as an exception for development convenience. The reason is
direct: a layer that intervenes between the page and the network can permanently alter the
entire page once it falls into the hands of someone listening on the network. The integrity
guarantee from the Role of HTTPS lesson is a precondition for this authority.

## Scope and Control

A registration governs not the whole origin but a path prefix. This prefix is called
**scope**, and two separate rules determine it.

The first is the declaration's validity: a worker cannot declare a scope wider than the
directory its script sits in. A script under `/assets/js/` cannot govern the whole origin.
This limit can be lifted, but only by the server granting the permission with a header — that
is, by the decision of whoever configures the server, not whoever uploads the file.

The second is mapping: a page is controlled by the registration whose scope is a prefix of
that page's path and is the **longest** among those that match. This is the same logic as the
longest-prefix matching in the IP Address Concept lesson.

```js
// scope.mjs — matching registration scope with the longest-prefix rule
// Scope cannot be wider than the directory the script sits in; widening it needs a
// permission header from the server. The two functions below test these rules separately.

const directory = (path) => path.slice(0, path.lastIndexOf("/") + 1);

function isScopeValid(scriptPath, requestedScope, allowedParent = null) {
  const widest = allowedParent ?? directory(scriptPath);
  return requestedScope.startsWith(widest);
}

// Which registration controls a page: the one whose scope is a prefix of the page's
// path and is the LONGEST among those that match.
function controllingRegistration(registrations, pagePath) {
  const matching = registrations.filter((r) => pagePath.startsWith(r.scope));
  if (matching.length === 0) return null;
  return matching.reduce((a, b) => (b.scope.length > a.scope.length ? b : a));
}

const trials = [
  { script: "/sw.js", scope: "/" },
  { script: "/station/sw.js", scope: "/station/" },
  { script: "/station/sw.js", scope: "/" },
  { script: "/station/sw.js", scope: "/", permission: "/" },
  { script: "/assets/js/sw.js", scope: "/station/" },
];

console.log("is the scope declaration valid?");
for (const t of trials) {
  const result = isScopeValid(t.script, t.scope, t.permission);
  console.log(
    `  script ${t.script.padEnd(20)} scope ${t.scope.padEnd(12)}` +
    `${t.permission ? "permission " + t.permission : "no permission"} -> ${result ? "valid" : "rejected"}`);
}

const registrations = [
  { name: "root", scope: "/" },
  { name: "station", scope: "/station/" },
  { name: "north-slope", scope: "/station/north-slope/" },
];

console.log("\nwhich registration controls the page?");
for (const page of [
  "/",
  "/about",
  "/station/",
  "/station/log",
  "/station/north-slope/measurements",
]) console.log(`  ${page.padEnd(36)} -> ${controllingRegistration(registrations, page)?.name ?? "(uncontrolled)"}`);
```

```
is the scope declaration valid?
  script /sw.js               scope /           no permission -> valid
  script /station/sw.js       scope /station/   no permission -> valid
  script /station/sw.js       scope /           no permission -> rejected
  script /station/sw.js       scope /           permission / -> valid
  script /assets/js/sw.js     scope /station/   no permission -> rejected

which registration controls the page?
  /                                    -> root
  /about                               -> root
  /station/                            -> station
  /station/log                         -> station
  /station/north-slope/measurements    -> north-slope
```

The difference between the third and fourth lines shows that script placement is a deployment
decision. Putting a service worker script in the assets directory looks natural for gathering a
bundler's output in one place, and it locks the scope to that directory. The script sitting at
the root is not a technical requirement, it is where the widest scope declaration comes free.

One consequence of scope mapping is that a page can stay uncontrolled: a page outside every
scope is seen by no worker and goes straight to the network. Out-of-scope pages have no
offline behavior.

## Lifecycle and the Waiting State

A service worker does not start work the moment it is registered. The states in between exist
to keep two different versions from mixing on the same page.

**Installing** is the new version's first chance: it gathers the resources it will need ahead
of time. If installation fails, the version never activates and the old version stays in
place — this is the first safety lock keeping a half-finished deployment from breaking the
page.

**Waiting** is the finished version waiting until the pages the old version controls close.
The reasoning is that a page talks to a single version throughout its life: if a page was
loaded against the old version's cache and its requests get answered by the new version, that
produces version mixing.

**Activating** is the right place to clean up what the old version left behind; at that point
the old version controls no page.

```js
// worker-lifecycle.mjs — model of the installing, waiting, and activating states
const trace = [];

function registration() {
  return { active: null, waiting: null, installing: null, controlled: new Set() };
}

// A new version is registered: it installs, then either waits or activates directly.
function register(reg, version, { skipWaiting = false } = {}) {
  reg.installing = version;
  trace.push(`${version}: installing`);
  reg.installing = null;
  const hasControlled = reg.controlled.size > 0;
  if (reg.active && hasControlled && !skipWaiting) {
    reg.waiting = version;
    trace.push(`${version}: installed -> waiting (${reg.active} still controls ${reg.controlled.size} page(s))`);
  } else {
    activate(reg, version);
  }
}

function activate(reg, version) {
  if (reg.active) trace.push(`${reg.active}: now obsolete`);
  reg.active = version;
  reg.waiting = null;
  trace.push(`${version}: activating -> active`);
}

// Control is only established at navigation time; the claim call does not wait for it.
function openPage(reg, page) {
  reg.controlled.add(page);
  trace.push(`${page} opened -> controller: ${reg.active ?? "(none)"}`);
}

function closePage(reg, page) {
  reg.controlled.delete(page);
  trace.push(`${page} closed -> controlled pages: ${reg.controlled.size}`);
  if (reg.waiting && reg.controlled.size === 0) activate(reg, reg.waiting);
}

console.log("A. Waiting state: the old version waits until pages let go");
const a = registration();
register(a, "v1");
openPage(a, "measurements");
register(a, "v2");
openPage(a, "log");
closePage(a, "measurements");
closePage(a, "log");
console.log(trace.map((s) => "  " + s).join("\n"));

trace.length = 0;
console.log("\nB. Skipping the wait: the new version takes over open pages");
const b = registration();
register(b, "v1");
openPage(b, "measurements");
register(b, "v2", { skipWaiting: true });
console.log(trace.map((s) => "  " + s).join("\n"));
console.log(`  -> the 'measurements' page loaded with v1, its requests are now served by ${b.active}`);
```

```
A. Waiting state: the old version waits until pages let go
  v1: installing
  v1: activating -> active
  measurements opened -> controller: v1
  v2: installing
  v2: installed -> waiting (v1 still controls 1 page(s))
  log opened -> controller: v1
  measurements closed -> controlled pages: 1
  log closed -> controlled pages: 0
  v1: now obsolete
  v2: activating -> active

B. Skipping the wait: the new version takes over open pages
  v1: installing
  v1: activating -> active
  measurements opened -> controller: v1
  v2: installing
  v1: now obsolete
  v2: activating -> active
  -> the 'measurements' page loaded with v1, its requests are now served by v2
```

The first section's sixth line is a commonly missed behavior: a new page opened while `v2`
waits is still controlled by `v1`. What ends the waiting state is not reloading a tab, it is
**every** page belonging to the origin closing. Reloading a tab usually does not end the wait,
because it opens the new page before closing the old one.

The second section shows what skipping the wait does and what it risks. The new version
activates immediately and takes over open pages. The gain is that deployment spreads instantly;
the cost is that a page loaded under the old version has its requests answered by the new one.
This produces a break when the page's code and the worker's expectations diverge. Skipping the
wait is safe in deployments where cross-version compatibility is known to hold; it is not a
default habit applied to every deployment.

Updating the registration is a separate check: the browser re-requests the worker script at
intervals, and if the content differs from the previous one **at the byte level**, it counts as
a new version. This is why the worker script itself must not be cached long-lived; otherwise
the update check reads the old copy and the deployment is never seen.

## Answering Requests by Intervening

An active worker receives an event for every network request its controlled pages produce:
page navigations, scripts, stylesheets, images, data requests. The event object carries the
request; the worker does one of two things.

**If it does not respond**, the request continues on its usual path and goes to the network.
This means doing nothing is the safe default.

**If it responds**, the network may never be used at all. The response is declared as a
promise; the request waits until the promise resolves. The contract has a strict condition:
the decision to respond must be announced **synchronously** while the event callback is
running. If the decision is made after awaiting a promise, it comes too late; the browser has
already sent the request to the network in the meantime. Producing the response can be
asynchronous, announcing that a response will be given cannot.

Two details are commonly missed. First, the response body is **read once**; if it needs to be
both written to a cache and given to the page, it must be cloned. Second, a response coming
from another origin whose headers are closed to sharing is **opaque**: its status code cannot
be read, its content cannot be inspected, its size is unknown. Since whether such a response
succeeded cannot be determined, caching it can make an error page permanent.

There are requests the worker never sees too: requests from pages outside its scope, requests
arising from pages of another origin, and the update request for the worker's own script.

## The Irreversibility of Deployment

A service worker is a proxy deployed not to the server but to the user's device. A broken
version does not go away by being deleted from the server; it stays active on the user's
device and pins the page to the version it knows. A fix on the server does not reach the user
until the worker performs its update check and accepts the new script.

The counterpart to this is two habits. First, short-lived caching of the worker script — so
the update check genuinely reaches the network. Second, keeping a **minimal version** ready
that clears the registration and empties the caches: the only way to retire a worker whose
behavior is uncertain is to deploy a worker that removes itself in its place.

## Summary

- A service worker is bound to the origin, not the page; it can be terminated between events
  and cannot hold persistent state in its global scope.
- Scope is set by two rules: it cannot be wider than the script's directory, and a page is
  controlled by whichever matching registration has the longest scope.
- If installation fails, the version never activates; the waiting state lets a page talk to a
  single version throughout its life, and lasts until every page of the origin closes.
- Skipping the wait spreads deployment instantly, but pairs an old page with the new version;
  it is not done without knowing version compatibility.
- The decision to answer a request must be announced synchronously; the response body is read
  once, and an opaque response's success cannot be checked.
- The worker is a proxy deployed to the user's device; the way to roll back a broken version is
  to deploy a version that removes itself.

## Next Step

This lesson built the authority to intervene but left open what to do with it. There is no
single correct way to answer a request: the station's logo and the measurement list cannot be
served by the same rule. One never changes and asking the network is unnecessary; the other
changes every minute and an old copy is misleading. The choice between them is a deliberate
trade-off between speed and freshness, made separately for each kind of resource. The next
lesson takes up this decision together with how a cache key is produced and when a copy counts
as fresh.
