Lesson 17 / 24
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.
Contents
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.
// 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.
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.