Service worker

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.

Load it first

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')

API

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.

MemberSignatureDescription
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.

WorkerState

FieldValuesMeaning
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.

The worker's side

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 fieldMeaning
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.

Install

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.

Activate

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.

Fetch

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.

Messages, and whether a new version waits

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.

The step log

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.

Which version does this page run

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.

What browsers do

Measured on Playwright's Firefox and Chromium with a worker built from this script and this library:

Example

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

In tests

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.

Traps