service-worker.mjs · service-worker-scope.js
A page served through a precaching service worker runs whatever snapshot that worker holds, which is not always what the server publishes. This pair of files answers three questions for such a page: which version does this page run, which version will the next page load get, and has a newer one arrived. The page-side module follows the worker in control and the registration's updates. The worker-side script is the precaching worker itself: it installs the files a manifest lists, checking each one that has a hash, serves them, and answers for its own version. Every fact comes from the worker itself, never from guessing at cache names.
Import service-worker.mjs from the page's first module script. The default
instance reads navigator.serviceWorker.controller when the module is evaluated
and asks that worker its version at once. Read later, the controller can already be a worker
that took control after the page's files were served, and the answer would describe code the
page does not run. A page that only observes may import it anywhere; only
register() installs anything.
// preload.mjs — the page's first module script
import './ui/lib/service-worker.mjs'
// app.mjs — once per page
import service_worker from './ui/lib/service-worker.mjs'
service_worker.register('/service-worker.js')
The module exports the class ServiceWorkerWatcher and, as its default export,
one instance built on navigator.serviceWorker, or on null where the
API is absent (an insecure context, a DOM stand-in). A suite builds its own instance on a
stand-in container.
| Member | Signature | Description |
|---|---|---|
supported |
boolean |
Whether the page has the service worker API. False in an insecure context and under a DOM stand-in. |
loaded_under |
ServiceWorker | null |
The worker that answered this page's own requests when it loaded, or null when they went to the server. |
loaded_version |
Promise<unknown> |
What loaded_under answered as its version, or null. Asked at construction, so the answer is in before that worker can be replaced or turn redundant. |
installed_at_load |
Promise<boolean> |
Whether a registration with an active worker existed when the page loaded. False on a first visit and after the worker was turned off; true after a reload that bypassed it. |
version_of |
(worker) => Promise<unknown> |
Ask any worker its version over a message channel. Null when there is no worker, it cannot be messaged, or it does not answer within the deadline (3 s by default; the constructor's ask_timeout_ms sets it). |
register |
(url, {scope?, update_on_visible?}) => Promise<ServiceWorkerRegistration | null> |
Register the worker once the page has loaded, past the HTTP cache (updateViaCache: 'none'), and follow the registration. With update_on_visible (the default) it asks for an update check whenever the tab comes back into view, because the browser checks on navigation and a long session may never navigate. A second call returns the first registration. When the browser refuses the registration, the first call rejects, and a later call resolves with whatever registration the browser holds, or null. Null where the API is absent. |
registration |
() => Promise<ServiceWorkerRegistration | null> |
The registration this page uses: the one register() made, else the one the browser holds for this page. |
update |
() => void |
Ask the browser to check for a new worker now. It does nothing until the registration is known. A failed check, as when offline, is ignored. |
state |
() => WorkerState |
The state as of now (below). installed is false until the registration is known. |
current |
() => Promise<WorkerState> |
The state once the registration is known. |
switch_to |
(worker) => Promise<boolean> |
Ask a waiting worker to activate by posting {type: 'SKIP_WAITING'}. Every tab of the site then gets it. Resolves true once it controls this page, or false when it is discarded first. |
keep_log |
({key?, channel?, store?, limit?}) => void |
Keep every step this page and its workers take in localStorage under key (default service-worker-log), the latest limit (2000), in time order and each once: the steps the workers saved in the IndexedDB database store (default service-worker-log), read when this is called, the steps a worker posts on the BroadcastChannel channel (default service-worker-log) while the page is open, and the page's own. See The step log. Call once per page, early. |
log_entries |
() => LogEntry[] |
The steps keep_log has kept, oldest first; empty when it was not called. |
change event |
CustomEvent<WorkerState> |
Fired once register() has the registration, whenever a worker starts installing, finishes, fails, activates, or takes control, when the worker that served this page turns redundant, and when switch_to is called. detail is the state. |
switched event |
Event |
Fired when the worker this page asked for takes control. The page reloads itself here; the library never reloads a page. |
| Field | Values | Meaning |
|---|---|---|
served_by |
'worker' | 'server' |
Whether a worker answered this page's own requests when it loaded. Fixed for the page's life. |
controller |
'same' | 'changed' | 'none' |
The worker in control now, relative to load: the one that served the page, another one, or none. changed covers a switch pressed in another tab, the worker turned off, the worker that served the page turning redundant, and a page the server served being claimed. |
installed |
boolean |
A registration with an active worker exists, whether or not it serves this page. A page served by the server while this is true loaded past the worker, and the next load is served from the snapshot. |
active |
ServiceWorker | null |
The registration's active worker, whether or not it serves this page. Ask it with version_of for the version the next load runs. |
installing, waiting |
ServiceWorker | null |
A worker being installed, and one installed and waiting to activate. |
switching |
boolean |
This page asked a waiting worker to activate and waits for it to take control. |
blocked |
'unsupported' | 'refused' | 'failed' | null |
Why no worker can be installed, or null. unsupported: the page has no service worker API, as in an insecure context. refused: the browser refused register(). failed: the last worker this page followed turned redundant before it was installed; a new worker clears it, so it can also describe a failed update while an older version stays active. |
service-worker-scope.js is the precaching worker. It is not imported at run
time: a build step writes the served worker file as a statement that sets
self.SW_MANIFEST, followed by this file. One file, because an
importScripts() file is fetched on its own and can be answered from the HTTP
cache, and a worker whose parts arrive at different times can run one version's manifest
with another version's logic. The whole script is one block, so its bindings stay out of
the file's top-level scope. The worker is a classic script, because module service workers
are Baseline 2026, later than the library's Baseline 2024 target; see
Browser support.
// The generated worker, as served
self.SW_MANIFEST = {
"version": {"stamped_at": "2026-09-29T10:00:00.000Z", "commit": "1a2b3c4"},
"id": "3f9c2a7d41b0e865",
"headers": "8d0e51b27f4a93c6",
"files": {
"index.html": "9b1f…",
"page.html": "4e07…",
"app.mjs": "c2d8…",
"config.mjs": null
}
}
{ /* service-worker-scope.js */ }
| Manifest field | Meaning |
|---|---|
version |
What the worker answers as its version: any value that identifies the build, such as its stamp. |
id |
A name for this set of files. It changes when a path is added or removed, or when a file that has a hash changes; a file whose hash is null does not count. It is part of the cache name, so every version fills a cache of its own. It holds no ., which separates it from headers in that name. |
headers |
Optional, and without a .. A value that changes whenever the headers the server sends with these files do, such as a hash of the whole server configuration that sets them. A stored file keeps the headers it was downloaded with, so an install reuses a file only from a precache made under the same value; after a change, it downloads every file once. Without it, a header change reaches a stored file only with a change to the file's bytes. |
files |
Each file's path, relative to the worker script, and the SHA-256 of its bytes in hex. The worker answers these paths and nothing else. A path whose value is null is a file each site writes for itself: the install downloads whatever the site serves, the worker answers it from the stored copy, and replaces that copy with the server's current file for the next load. |
log_channel |
Optional. The BroadcastChannel the worker posts its steps on. The default is service-worker-log. |
log_store |
Optional. The IndexedDB database the worker saves its steps in. The default is service-worker-log. |
The worker fills a cache of its version's own, named
precache-<id>-<scope>, or
precache-<id>.<headers>-<scope> when the manifest has
headers, six files at a time. It takes each file from the first place whose bytes
hash to the manifest's value: this cache, where an interrupted install left it; another
precache- cache of the same scope made under the same headers, so an
unchanged file is not downloaded again; or the server, with cache: 'reload' so the HTTP cache
cannot answer with an older copy. The server must answer 200, and its bytes must match: a
mismatch fails the install, with an error naming the file and both hashes, and the browser
keeps the version it has. So a server caught halfway between two versions cannot put one
version's file into another version's cache. No entry is trusted for being present, because
any script of the origin can write to Cache Storage.
A file whose hash is null is downloaded from the server first, with
cache: 'reload'. No stored copy can be checked against this version, and a site
that changes the file together with a release needs the new version to start with the
site's current copy. Only when the server gives no whole 200 answer, because it cannot be
reached, answers another status or cuts the download off, does the install take a copy that
this cache or another precache holds, without a check (took the stored copy: the server
gave no file). When neither the server nor a cache has the file, the install fails.
A stored file keeps the server's headers, Cross-Origin-Opener-Policy and
Cross-Origin-Embedder-Policy among them, so a page served from the cache stays
cross-origin isolated and can still start its workers. It drops Content-Encoding,
Content-Length and Vary, which described the encoded body the
server sent and not the decoded one stored. It is stored under its address without a query.
Activation first copies back, from the precache- caches an install may reuse and
checking each file that has a hash, any listed file missing from the version's cache. It has to: a waiting version can
activate while a newer one installs, since activation never looks at the installing worker,
and its cleanup then deletes the newer version's half-filled cache. It never asks the server,
because every page waits for activation and a slow download would hang them all. The worker
then deletes every other cache of its scope whose name contains precache,
whichever worker named it, and no other cache. While files are still missing, it keeps the
caches it may reuse, which a request may copy from.
The worker answers a same-origin GET without a Range header whose
path, without its query, is a listed file, or a directory whose index.html is
listed. A static file's bytes never depend on the query, so page.html?id=5 is
page.html. It answers a file that has a hash from its version's cache. A listed
file the cache lost is put back the way an install does, from another precache or the server
with the hash checked, and then served; only when that fails does the request go to the
network. A file whose hash is null is answered from the stored copy at once, so
a stalled or slow server never holds up the page. For each such request the worker also
fetches the file from the server with cache: 'no-cache' and, when the whole body
of a 200 answer arrives, stores it for the next load. Any other answer, an unreachable
server, a download cut off midway or a copy that cannot be stored leaves the stored copy as
it was. Without a stored copy, the page gets the server's answer, or a network error when
the server cannot be reached. The worker never sends the request a second time. It adds no
.html to a path without one. Every other request
goes to the network as though no worker were there.
{type: 'GET_VERSION'} on the message's first port
with {version, prompts: true}, version from the manifest.
prompts says the pages of this version can ask a waiting worker to
activate.{type: 'SKIP_WAITING'}, which
switch_to posts.SKIP_WAITING. That holds for an active worker of any version, including one
that predates this library and whose pages have no Switch control, so an open page never
gets files of a version other than the one it runs. While a page of such a version, or a
shared worker one of its pages started, stays open, the new version keeps waiting.
The worker claims no page: a first install leaves the page that registered it to the server.
A claimed page keeps running the files it loaded, and after a reload that bypassed the worker
or a visit with none installed those files came from the server, or from the browser's HTTP
cache, which may hold an older publish when the server sends no Cache-Control. A
worker that claimed such a page would answer for code it did not serve. Left unclaimed, the
page reads its version from the server and the next load is served from the snapshot.
Activation still moves every page the old worker controlled to the new one and fires
controllerchange in each; that is the specification's activate step and needs no
claim.
When an update goes wrong on someone else's machine, what the worker did is otherwise lost.
The worker logs each step that helps explain a problem with it: the install, with the
number of files and whether an active worker was there to wait behind; the fill, with its
number of files at the start, the counts so far every 50 files, and at the end how many files
it kept, copied and downloaded and how many it could not find; a file whose hash is
null that the install took from a stored copy because the server gave no file
(took the stored copy: the server gave no file, with the source and
why); each file that fails, and the install's outcome; the activation, with the number of files it had to put back, each cache
it deletes and each precache it keeps because its own version's cache is incomplete; a lost
file a request put back or had to take from the network; a file whose hash is
null whose stored copy could not be refreshed because the server cannot be
reached (could not refresh the stored copy: the server cannot be reached), did
not answer 200 (could not refresh the stored copy: the server did not answer 200,
with the status), or sent an answer that could not be read to its end or stored
(could not refresh the stored copy: the server's answer could not be read or
stored); a request for a same-origin path the manifest does not list (not in the
snapshot, with the path and without the query, once per path for each version of the
worker), because a page that needs that file cannot load it offline; and a
SKIP_WAITING. A file that downloads as it should is not logged on its own. A
browser may stop a long install without an error, so the last progress step then shows how
far it got, and when.
The worker writes each step to its console, as [service worker] <step>,
which the browser's inspector for service workers shows (Firefox:
about:debugging → This Firefox → Inspect; Chromium:
chrome://inspect/#service-workers). It saves each step in the IndexedDB database
the manifest names in log_store (default service-worker-log), in
the object store steps, the latest 1000, and holds the install and activate
events until their steps are written. So the steps of an install that ran while only pages
that keep no log were open, as pages of an earlier version of the site, are still there
afterwards. It also posts each step on a BroadcastChannel
(SW_MANIFEST.log_channel, default service-worker-log).
A page that calls keep_log() writes the steps the workers saved, those they
post while it is open, and its own to localStorage. The page writes new steps
together a quarter of a second later, and at pagehide. Each write holds a Web
Lock named after the key, so one tab does not overwrite another's write. A page reading the
database creates none, so the worker that creates it gives it its store.
// Once per page, as early as possible
service_worker.keep_log({ key: 'my-app:worker-log' })
// Read it back, in the console or a bug report
JSON.parse(localStorage.getItem('my-app:worker-log'))
// [{id, at: '2026-09-29T10:00:00.000Z', side: 'worker', worker: '3f9c2a7d41b0e865.8d0e51b27f4a93c6',
// scope: 'https://example.test/', step: 'filled', kept: 280, copied: 0, downloaded: 30, missing: 0}, …]
// Read what the worker saved from any page of the site, including one that keeps no log
await new Promise(resolve => {
const request = indexedDB.open('service-worker-log')
request.onsuccess = () => {
const all = request.result.transaction('steps').objectStore('steps').getAll()
all.onsuccess = () => resolve(all.result)
}
})
On a site no worker has saved steps for, the second read throws, because the database it
creates has no steps store; a worker that logs later adds the store.
Every entry has an id, the time at, the side that took
the step (page or worker) and the step; a worker's
entries also carry the scope and, as worker, the manifest id
followed by .<headers> when the manifest has them; a page's entries carry
its path, without the query, as page.
A path in a worker's step is relative to the worker script, as in the manifest.
The worker's version is in its install step. Several tabs write the same list; a step already in it is not added again.
Ask the page's own worker: loaded_version. It answers for the snapshot the
page's files came from, and the answer was collected before another worker could take
control. A page the server served has no worker to ask; give it a stamp module that loads
with its code, as the example below does. A fetched version file names what the server holds
when it answers, which can be a newer publish than the page loaded. Do not read that file on
a page a worker served either: the request goes to whichever worker controls the page
now, and after a switch that is a newer snapshot than the one the page runs. The one
use left for it is a backup: when the page's own worker does not answer in time and still
controls the page, its precache answers the file with the page's own stamp.
Measured on Playwright's Firefox and Chromium with a worker built from this script and this library:
served_by is 'server'), the
browser installs the new version again, and it serves the next load.location.reload(true) in Firefox) leaves the page uncontrolled for its whole
life: served_by is 'server' and controller stays
'none'. Chromium's cache-ignoring reload does not count the bypassed page as a
client of the old worker, so a waiting worker activates during it.Cache-Control from the server, Firefox reused every module and the document
itself from an older publish without asking the server; Chromium revalidated the document
and some modules and kept others, so one page ran modules of two publishes. The version file
fetched with no-cache was current in both. Serve the shell with
Cache-Control: no-cache.controllerchange when a new worker
activates, whether Switch was pressed in that tab or another, and without
clients.claim().self.registration.active as null inside
a worker (Bugzilla 1113522, passing on wpt.fyi from 133), so the
install step's active reads false there even
behind an active worker.import service_worker from '/ui/lib/service-worker.mjs'
// A stamp module the build writes beside the version file, loaded with the page's code.
import bundled_stamp from '/version-stamp.mjs'
service_worker.addEventListener('change', ({ detail: state }) => {
if (state.controller === 'changed') say('A newer version took over. Reload to use it.')
else if (state.installing) say('A new version is being installed')
else if (state.waiting) offer('Switch now', () => service_worker.switch_to(state.waiting))
else clear()
})
service_worker.addEventListener('switched', () => location.reload())
await service_worker.register('/service-worker.js')
// The version this page runs, for a menu row.
const state = await service_worker.current()
const version = state.served_by === 'worker'
? await service_worker.loaded_version
: bundled_stamp
Node has no service worker API. Build a ServiceWorkerWatcher on stand-ins for
the container, the registration and the workers, and fire the events a browser fires in the
order it fires them: the registration's installing and waiting
change before the worker's statechange, and the container's
controller changes before controllerchange. A stand-in worker
answers GET_VERSION by posting on the transferred port. Pass a short
ask_timeout_ms, and let the worker-side script run under
vm.runInNewContext with a setTimeout that shortens its deadline.
Service.stop_all() (Service) ends every
question still waiting for an answer, so a suite's teardown does not wait out the deadline.
It also writes the log steps not yet written and closes the log channel.
For the precaching itself, give the script a stand-in BroadcastChannel that records
the steps it logs (a real one keeps a Node process alive), a stand-in console, an
in-memory IndexedDB such as fake-indexeddb, an in-memory Cache Storage that behaves as a
browser's does where the worker depends on it (match returns a new response each
time, put refuses a 206 and Vary: *), a stand-in server behind
fetch, and plain objects as fetch events. What only a browser decides, whether a
page from the cache is cross-origin isolated and which files the worker answered, needs a
browser test: serve a temporary tree from a server that logs each path it is asked for,
since Playwright's network events miss the requests a service worker makes.
null may change at any time.headers when the server's headers matter to the page.
Without it, an unchanged file is copied from the earlier version's cache with the headers it
was first downloaded with, so a changed header reaches it only with a change to its bytes.
A cross-origin isolation header lost that way stops the page's workers without an
error.