Advanced Service Worker Techniques¶
Advanced service worker techniques are what you need once the basic install, activate and fetch loop works: module workers, the exact rules for importScripts(), bundling and type-checking the worker, coordinating it with open tabs, and knowing exactly how long the browser will let it run. They also cover using the worker as a programmable edge layer inside the browser, and the places where service workers behave differently or not at all, such as embedded WebViews and origins with several registrations. This page covers each of these at the level of spec algorithms, engine source code and production code.
Key takeaways
- Module service workers (
register(url, { type: "module" })) ship in Chrome/Edge 91, Safari 15 and Firefox 147. Staticimportworks, butimport()rejects with aTypeError, top-levelawaitmakes registration fail, andimportScripts()throws. importScripts()fetches new URLs only while the worker isparsedorinstalling. After that, only URLs already stored in the worker's script resource map can be imported. Chromium throws aNetworkErrorfor anything else.- No engine lets a service worker run indefinitely. Chromium terminates the worker when a single event runs longer than 5 minutes (90 seconds for
push) and stops idle workers after 30 seconds. Firefox allows 30 seconds plus a 30-second grace period. WebKit gives up on navigation fetch events after 70 seconds. - Web Locks, IndexedDB, the Cookie Store API and WebAssembly all work inside service workers. Design each use so it survives the worker being torn down between events.
- A service worker can act like an in-browser edge function: rewriting requests, assigning experiment buckets, transcoding images, fronting APIs and queueing analytics. Every one of these must fall back to the network when it fails.
- Embedded WebViews are the weak spot. iOS
WKWebViewdisables service workers unless the app uses App-Bound Domains or holds the web-browser entitlement, and AndroidWebViewneedsServiceWorkerControllerto see requests that the worker makes. - Several registrations on one origin share Cache Storage, IndexedDB and cookies. Give every cache a namespace, and never delete caches that another worker owns.
Module service workers¶
A module service worker is an ES module script (import/export, strict mode, import.meta) that runs as the service worker's top-level script. You opt in per registration with type: "module". The worker's type is stored on the service worker, and in the Update algorithm a change of type counts as an update, just like a change of bytes.
Registering a module worker¶
// Register a module service worker. Runs in the page (window context).
export async function registerServiceWorker() {
if (!("serviceWorker" in navigator)) return null; // (1)!
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
type: "module", // parse /sw.js and its static imports as ES modules
scope: "/", // default is the script's directory; explicit is clearer
updateViaCache: "none" // revalidate sw.js and every import on update checks
});
return registration;
} catch (error) {
// TypeError: script failed to evaluate (SyntaxError, top-level await, throw)
// SecurityError: wrong MIME type, scope outside the max scope, insecure origin
console.error("[sw] registration failed:", error);
return null;
}
}
- Service workers exist only in secure contexts (
https:orlocalhost). Onhttp:pages,navigator.serviceWorkerisundefined.
The register() options and their defaults, from the Service Worker specification's RegistrationOptions dictionary:
| Option | Values | Default | Effect |
|---|---|---|---|
scope | URL string | Directory of the script URL (./ resolved against it) | Prefix of URLs this registration controls. It must sit under the max scope unless the script response sends Service-Worker-Allowed. |
type | "classic", "module" | "classic" | How the top-level script and its dependencies are fetched and evaluated |
updateViaCache | "imports", "all", "none" | "imports" | Whether update checks may use the HTTP cache for the main script and its imports |
For a module worker, the Update algorithm runs "fetch a module worker script graph" with credentials mode "same-origin" and destination "serviceworker". Every static import is fetched, parsed and linked before the worker is considered installed. If any module in the graph fails to fetch, fails to parse or throws during evaluation, register() rejects with a TypeError. If this was the registration's first install, the registration is removed.
What the module graph can and cannot do¶
| Capability | Classic service worker | Module service worker |
|---|---|---|
Static import / export | ❌ SyntaxError | ✅ |
Dynamic import() | ❌ rejects with TypeError | ❌ rejects with TypeError |
importScripts() | ✅ (subject to install-time rules) | ❌ throws TypeError |
Top-level await | ❌ not valid in scripts | ❌ registration fails (async module graph) |
import.meta.url | ❌ | ✅ |
| Strict mode | Opt-in ("use strict") | Always |
| Cross-origin dependencies | importScripts() needs no CORS | Static imports require CORS headers |
| Import maps | ❌ | ❌ (import maps are not applied to workers) |
Why import() is banned. HTML's HostLoadImportedModule hook rejects dynamic imports when the referrer's global object is a ServiceWorkerGlobalScope. The design goal is the same one behind the importScripts() rules: the browser must know, and be able to store, every byte of the worker's code by the end of install. Otherwise an offline restart could need code that was never downloaded. Chromium's rejection message states this directly:
TypeError: import() is disallowed on ServiceWorkerGlobalScope by the HTML specification.
See https://github.com/w3c/ServiceWorker/issues/1356.
Why top-level await is banned. In the Update algorithm, after the script is fetched, the spec runs Is Async Module over the graph. If any module in it uses top-level await, the job is rejected with a TypeError. A comment in Chromium's worker evaluation code says the same thing: "Service workers prohibit async module graphs (those with top-level await)". Watch your bundler here. If you emit ESM and any dependency uses top-level await, the bundler keeps it, and registration fails with an unhelpful TypeError. Move async initialization into the install or activate handler, or into a lazily awaited promise:
import { openDatabase } from "./db.js";
// ❌ const db = await openDatabase(); // async graph -> registration TypeError
// ✅ Create the promise lazily; every handler awaits it.
let dbPromise;
export function db() {
dbPromise ??= openDatabase().catch((error) => {
dbPromise = undefined; // allow a retry on the next event
throw error;
});
return dbPromise;
}
How updates see changes in imported modules¶
The bytes of the top-level script are compared byte for byte on every update check. For dependencies:
- The web.dev article on ES modules in service workers says that in Chromium, "Scripts imported via ES modules can trigger the service worker update flow if their contents change, matching the behavior of
importScripts()." - In the specification, every request in the module graph passes through the same fetch hook as the top-level script. That hook sets the cache mode to
no-cachewheneverupdateViaCacheis not"all", so module dependencies are revalidated with the server under the default"imports"setting too.
The dependable pattern across engines is to fingerprint imported file names (./router.3f9a1c.js). Any change to a dependency then changes the import specifier inside sw.js, which makes the top-level bytes differ and guarantees an update. Bundling (covered below) gets you the same result.
Feature detection and a classic fallback¶
Engines without module service workers include Firefox 146 and earlier, which covers Firefox ESR 140 (ESR 153, released in July 2026, supports them). These engines evaluate the file as a classic script, hit a SyntaxError on the first import, and reject the registration with a TypeError. No clean synchronous feature test exists, so production code usually tries the module build and falls back:
const MODULE_SW = "/sw.mjs"; // ESM build, served as text/javascript
const CLASSIC_SW = "/sw-classic.js"; // same code bundled as an IIFE
export async function registerWithFallback() {
if (!("serviceWorker" in navigator)) return null;
try {
return await navigator.serviceWorker.register(MODULE_SW, { type: "module" });
} catch (error) {
// Also reached for real failures (offline, 404). Registering the classic
// build is still correct because it contains the same code.
console.warn("[sw] module registration failed, using classic build", error);
return navigator.serviceWorker.register(CLASSIC_SW);
}
}
Switching script URLs is an update
A registration keeps one script URL. When a user upgrades the browser and the module registration starts to succeed, register(MODULE_SW) replaces the classic script through a normal update, so the new worker goes through install, waiting and activate. This works, but it is a real version change. Don't flip between the two builds on every page load, for example based on a flaky condition. For why changing script URLs is otherwise an anti-pattern, see Pitfalls & Anti-Patterns.
Module service worker support:
| Browser | Module service workers | Notes |
|---|---|---|
| Chrome / Edge (desktop & Android) | ✅ 91 | Also Android WebView 91 |
| Safari (macOS & iOS/iPadOS) | ✅ 15 | |
| Firefox (desktop & Android) | ✅ 147 | Shipped January 13, 2026 (bug 1360870) |
Support data as of September 2026. For live data, see the MDN compatibility table for ServiceWorker.
Cross-origin module imports¶
Static imports use CORS mode. A module on https://cdn.example.net must be served with Access-Control-Allow-Origin and a JavaScript MIME type. Otherwise the whole graph fails to fetch and registration rejects. For a service worker, a cross-origin import also adds a runtime dependency on a third-party origin, and that origin can change your worker's behavior on the next update. Self-host worker dependencies, or bundle them.
importScripts() semantics and timing¶
importScripts(...urls) is the classic worker's way to load code. It is synchronous: it fetches, compiles and runs each script in order in the global scope before it returns. Inside a service worker it comes with extra rules, defined in §6.3.2 of the specification in terms of the worker's script resource map. That map is a per-worker store of every script response the worker has used, and it is persisted together with the worker.
The state-based rule¶
When importScripts() fetches a URL, the spec's fetch hook does the following:
- If the worker's state is not
"parsed"or"installing", returnmap[url]if it exists, or a network error otherwise. - If
map[url]exists, use it. No network request is made. - Otherwise, fetch it. The fetch bypasses service workers, and uses
no-cachewhenupdateViaCacheis"none"or the registration is stale. Reject "bad import script responses" (non-OK status, non-JavaScript MIME type). Store the result in the map.
In practice:
When importScripts(url) runs | URL already in map? | Result |
|---|---|---|
Top-level script evaluation of a new worker (parsed) | No | Fetched from network, stored |
Inside the install handler (installing) | No | Fetched from network, stored |
After install (installed, activating, activated), for example in fetch or message | Yes | Served from the map, no network request |
| After install | No | Network error. Chromium throws NetworkError |
| Any time, in a module worker | n/a | TypeError |
Chromium's exception text makes the cause clear:
Uncaught NetworkError: Failed to import 'https://example.com/lazy.js'.
importScripts() of new scripts after service worker installation is not allowed.
Lazy imports that still work offline¶
Because installing counts, you can defer the execution of an expensive dependency without breaking offline restarts. Import it once during install so it enters the map, then import it again on demand later. The second call is served from the stored copy:
importScripts("/sw/router.js"); // needed on every start: runs at top level
const LAZY = "/sw/pdf-tools.js"; // large, rarely needed
self.addEventListener("install", (event) => {
// Putting it in the script resource map during install is enough.
// Its top-level code runs now, so keep that code side-effect free.
importScripts(LAZY);
});
self.addEventListener("message", (event) => {
if (event.data?.type !== "MAKE_PDF") return;
// Allowed after install because LAZY is already in the map.
// Guard against double evaluation when the worker has not restarted.
if (typeof self.PdfTools === "undefined") importScripts(LAZY);
event.waitUntil(self.PdfTools.render(event.data.payload));
});
Update checks for imported scripts¶
Since Chrome 78, each update check also re-fetches every URL in the script resource map and compares its bytes with the stored copy. The spec's Update algorithm requires the same of every engine. A change in any imported script triggers an update even when sw.js is unchanged. Two details decide whether the check actually sees your change:
updateViaCache. With the default"imports", the top-level script is always revalidated with the server, but imported scripts may be served from the HTTP cache. An import withCache-Control: max-age=31536000and an unchanged URL is effectively frozen for as long as the HTTP cache keeps it. Either fingerprint import URLs, or register withupdateViaCache: "none". The Updating Service Workers page covers the whole update flow.- Bad responses are ignored. A 404 or an HTML error page returned for an imported script during an update check is skipped for the comparison. It neither triggers nor blocks the update. Per the spec note, only good responses count.
Redirects, CORS and CSP for imports¶
- The spec sets redirect mode
"error"only for the top-level script. In practice, Chromium's service worker script loader also refuses redirects for imported scripts: a source comment tracks following them as an open TODO (crbug.com/40595655), and the script fails with "The script resource is behind a redirect, which is disallowed." Serve imports from their final URL. - Cross-origin
importScripts()works without CORS because it is a classic script load. Errors from such a script are muted ("Script error."), which makes debugging painful. - The worker's CSP comes from the headers of the
sw.jsresponse, not from the page (spec §6.2). Imported scripts have thescriptdestination, so the worker'sscript-src(falling back todefault-src) governs whatimportScripts()may load. The page'sworker-srcgoverns which script URLs the page may register. See Content Security Policy.
Bundling service workers¶
Bundling turns the worker's source tree into one file with a stable URL. The benefits are concrete:
- One network request per update check instead of one per import, and no module-graph resolution at every worker start. Service workers are started often: potentially on every navigation after an idle termination.
- A classic output that works everywhere. An IIFE bundle needs no
type: "module", so the Firefox fallback question disappears. - Byte changes that track dependency changes. Any change to any bundled dependency changes
sw.jsitself, so update detection is reliable without relying on per-import checks. - Build-time injection of the precache manifest, a version string and environment flags.
Bundling rules specific to service workers:
- Output format
iife(oresmonly if you register withtype: "module"). - No code splitting. A split chunk becomes an
import()(banned) or an extra file the worker cannot fetch after install. - No top-level
awaitin the output when the format is ESM (see above). - Stable output name (
sw.js), served from the scope root. Never content-hash the worker's own file name. Pitfalls & Anti-Patterns explains why. - Replace
process.env.NODE_ENV. Libraries such as Workbox branch on it, and service workers have noprocess. - Browser/worker conditions. Resolve packages with the
browserorworkerexport conditions, and make sure nothing referenceswindowordocument.
esbuild¶
esbuild is the smallest setup. This script builds the app first, then computes a precache manifest from the output directory and injects it into the worker. The build ID derives from the manifest, so a changed asset produces a changed sw.js.
// Usage: node scripts/build-sw.mjs (run after the app build has filled dist/)
import { build } from "esbuild";
import { createHash } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
const DIST = "dist";
const PRECACHE = /\.(?:html|js|css|woff2|svg|webp|avif|png|json)$/;
const SKIP = new Set(["sw.js", "sw.js.map"]);
async function listFiles(dir) {
const out = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...(await listFiles(full)));
else out.push(full);
}
return out;
}
async function precacheManifest() {
const files = (await listFiles(DIST))
.map((file) => path.relative(DIST, file).split(path.sep).join("/"))
.filter((rel) => PRECACHE.test(rel) && !SKIP.has(rel) && !rel.endsWith(".map"))
.sort(); // deterministic order -> deterministic bytes
return Promise.all(
files.map(async (rel) => ({
url: `/${rel}`,
revision: createHash("sha256")
.update(await readFile(path.join(DIST, rel)))
.digest("hex")
.slice(0, 16),
})),
);
}
const manifest = await precacheManifest();
const version = createHash("sha256")
.update(JSON.stringify(manifest))
.digest("hex")
.slice(0, 12);
await build({
entryPoints: ["src/sw/index.ts"],
outfile: `${DIST}/sw.js`,
bundle: true,
format: "iife", // classic worker: no module graph, works everywhere
platform: "browser",
target: ["es2020"],
minify: true,
sourcemap: "linked",
legalComments: "none",
define: {
"process.env.NODE_ENV": '"production"',
__SW_VERSION__: JSON.stringify(version),
__PRECACHE_MANIFEST__: JSON.stringify(manifest),
},
logLevel: "info",
});
console.log(`sw.js built: version ${version}, ${manifest.length} precached files`);
esbuild's define accepts JSON values, including arrays and objects, so the manifest is inlined as a literal. TypeScript is stripped without type checking. Run tsc -p tsconfig.sw.json separately (see the next section).
Rollup¶
import { nodeResolve } from "@rollup/plugin-node-resolve";
import replace from "@rollup/plugin-replace";
import terser from "@rollup/plugin-terser";
import typescript from "@rollup/plugin-typescript";
export default {
input: "src/sw/index.ts",
output: {
file: "dist/sw.js",
format: "iife",
sourcemap: true,
// IIFE output cannot code-split; inline any stray dynamic import instead
// of failing the build. (A dynamic import would reject at runtime anyway.)
inlineDynamicImports: true,
},
plugins: [
replace({
preventAssignment: true,
values: { "process.env.NODE_ENV": JSON.stringify("production") },
}),
nodeResolve({ browser: true, exportConditions: ["worker", "browser"] }),
typescript({ tsconfig: "./tsconfig.sw.json" }),
terser(),
],
};
Vite¶
Vite's main build targets documents and splits code aggressively. Build the worker as a second, library-mode build that writes a single file into the same dist/:
import { defineConfig } from "vite";
export default defineConfig({
publicDir: false, // the app build already copied public/
define: {
// Library mode does not replace process.env.* on its own.
"process.env.NODE_ENV": JSON.stringify("production"),
},
build: {
outDir: "dist",
emptyOutDir: false, // keep the app build's output
sourcemap: true,
lib: {
entry: "src/sw/index.ts",
formats: ["iife"],
name: "sw", // required for IIFE; the global it creates is unused
fileName: () => "sw.js",
},
},
});
{
"scripts": {
"build": "vite build && vite build --config vite.sw.config.ts && tsc -p tsconfig.sw.json"
}
}
Vite 8 replaced Rollup with Rolldown and renamed build.rollupOptions to build.rolldownOptions. The old name still works as a deprecated alias. The library-mode configuration above does not touch either option. If you need precaching with a generated manifest rather than a hand-written worker, the injectManifest strategy of the Vite PWA plugin (built on Workbox) does the same job with less code.
TypeScript setup for service workers¶
The DOM library and the WebWorker library declare conflicting globals: self, onmessage, postMessage, location and others. Type-check the worker as its own project with lib: ["WebWorker"] and without "DOM":
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "WebWorker"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"types": []
},
"include": ["src/sw/**/*.ts"]
}
"types": [] keeps ambient packages such as @types/node out of the worker program, because they would declare process and a conflicting self. Exclude src/sw from the app's main tsconfig.json.
In lib.webworker.d.ts, self is typed as WorkerGlobalScope & typeof globalThis. Narrow it once per file. A module-scoped declare const shadows the global declaration:
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
// Build-time constants injected by the bundler's define option.
declare const __SW_VERSION__: string;
declare const __PRECACHE_MANIFEST__: ReadonlyArray<{ url: string; revision: string }>;
export {}; // makes this file a module so the declarations above stay file-scoped
const PREFIX = "app";
const PRECACHE = `${PREFIX}-precache-${__SW_VERSION__}`;
// Messages the page may send. A discriminated union gives exhaustive switches.
type ClientMessage =
| { type: "SKIP_WAITING" }
| { type: "GET_VERSION" }
| { type: "CLEAR_RUNTIME_CACHE" };
self.addEventListener("install", (event) => {
// `event` is inferred as ExtendableEvent from ServiceWorkerGlobalScopeEventMap.
event.waitUntil(
(async () => {
const cache = await caches.open(PRECACHE);
await cache.addAll(__PRECACHE_MANIFEST__.map((entry) => entry.url));
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key.startsWith(`${PREFIX}-precache-`) && key !== PRECACHE)
.map((key) => caches.delete(key)),
);
await self.clients.claim();
})(),
);
});
self.addEventListener("fetch", (event) => {
// `event` is FetchEvent; respondWith must be called synchronously.
if (event.request.method !== "GET") return;
event.respondWith(
(async () => {
const cached = await caches.match(event.request, { cacheName: PRECACHE });
return cached ?? fetch(event.request);
})(),
);
});
self.addEventListener("message", (event) => {
const message = event.data as ClientMessage | undefined;
if (!message) return;
switch (message.type) {
case "SKIP_WAITING":
event.waitUntil(self.skipWaiting());
break;
case "GET_VERSION": {
// event.source is Client | ServiceWorker | MessagePort | null.
// Only Client has an `id`, so this check narrows the union.
const source = event.source;
if (source && "id" in source) {
source.postMessage({ type: "VERSION", version: __SW_VERSION__ });
}
break;
}
case "CLEAR_RUNTIME_CACHE":
event.waitUntil(caches.delete(`${PREFIX}-runtime`));
break;
default: {
const unreachable: never = message; // compile error if a case is missing
console.warn("[sw] unknown message", unreachable);
}
}
});
Notes on the typings:
- With Workbox's
injectManifest, the placeholder isself.__WB_MANIFEST. Declare it asdeclare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: Array<PrecacheEntry | string> }, importingPrecacheEntryfromworkbox-precaching. - Some newer or engine-specific APIs (Background Sync's
SyncEvent, Periodic Sync, Background Fetch, orcookieStoredepending on your TypeScript version) may be missing from the bundled lib files. Declare the pieces you use in a localsw-env.d.ts, and feature-detect at runtime. - Keep the message types in a file shared by the page and the worker, so both sides of
postMessageare checked against the same union. See Messaging & the Clients API.
Lifetime limits of extendable events¶
The specification deliberately leaves worker lifetime to the user agent. An ExtendableEvent holds a pending promises count. The user agent "should not terminate a service worker if Service Worker Has No Pending Events returns false". It also defines a timed out flag that is "set after an optional user agent imposed delay if the pending promises count is greater than zero." Every engine uses that permission. Three rules hold everywhere:
respondWith()must be called synchronously during dispatch of thefetchevent; a later call throwsInvalidStateError.waitUntil()must also be called during dispatch, with one exception: it may be called later while at least one lifetime promise added earlier is still pending. Otherwise it throwsInvalidStateError; in Chromium the message is "The event handler is already finished and no extend lifetime promises are outstanding."- Pending promises delay termination but never prevent it. Every engine has a hard ceiling.
- After termination all JavaScript state is gone. The next event starts a fresh global scope and re-runs your top-level script.
Per-engine limits¶
The values below come from each engine's source code, current as of September 2026. They are implementation details, not spec requirements, and they change between releases, so design for them rather than against them.
| Engine | Idle termination | Ceiling per event | Special cases |
|---|---|---|---|
| Chromium (Chrome, Edge, Android WebView) | 30 s after the last event settles (kServiceWorkerDefaultIdleDelayInSeconds) | 5 min (kRequestTimeout). Applies to install, activate, fetch, message and most other events. On timeout the worker is killed | push and pushsubscriptionchange: 90 s (kPushEventTimeoutSeconds). sync and periodicsync: 3 min (kMaxSyncEventDuration). After notificationclick, paymentrequest or backgroundfetchclick: one openWindow()/focus(), within 10 s when you use waitUntil() |
| Firefox | 30 s after each event (dom.serviceWorkers.idle_timeout = 30000) | After the idle timeout, a further 30 s grace period for pending waitUntil()/respondWith() (dom.serviceWorkers.idle_extended_timeout = 30000). Then the worker is terminated | A message from another service worker only propagates the sender's deadline and grants no fresh 30 s. openWindow() after a notification click: 1 s on desktop, 5 s on Android (dom.webnotifications.disable_open_click_delay) |
| WebKit (Safari, all iOS browsers, WKWebView) | Tied to clients. About 10 s after the last client of the origin goes away (defaultTerminationDelay), or immediately under memory pressure. About 10 s after a functional event such as push completes when no client exists (the same defaultTerminationDelay); the 2 s defaultFunctionalEventDuration applies only when the embedder disables the termination delay (serviceWorkerProcessTerminationDelayEnabled, on by default) | Navigation fetch events: 70 s (defaultServiceWorkerFetchTimeout). On timeout the navigation falls back to the network and the worker is terminated. A 60 s heartbeat terminates a worker whose thread stops responding | The navigation timeout applies only to main-document requests. A source comment notes that applying it to subresources "is not Web-compatible" |
Sources: Chromium service_worker_version.h, service_worker.mojom and background_sync_parameters.cc, Firefox all.js, WebKit SWServer.h and NetworkProcess.cpp.
When Firefox kills a worker at the end of the grace period, it logs a console message you can search for:
Terminating ServiceWorker for scope 'https://example.com/' with pending
waitUntil/respondWith promises because of grace timeout.
Designing work that survives termination¶
Treat roughly 20 seconds as a safe budget for one unit of work. That is the lowest common denominator once Firefox's 30 + 30 second model and slow devices are taken into account. Anything longer needs one of these approaches:
- Checkpoint to IndexedDB and resume. Process a queue in batches, record progress after each batch, and continue on the next event (
sync,message, the next navigation'sfetch). - Hand large downloads to the browser. Background Fetch (Chromium-only) runs outside the worker's lifetime and wakes it when finished.
- Retry through Background Sync. A rejected
syncpromise makes Chromium schedule a retry, andevent.lastChancetells you when the retries are running out. See Background Sync. - Keep interactive work in the page. Work that only matters while a tab is open belongs in a dedicated worker owned by that tab, not in the service worker.
// IndexedDB-backed queue (bundled): peekOldest() -> item | undefined, delete(id).
// Build it on the openDb() helper shown in the IndexedDB section below.
import { outbox } from "./sw-outbox.js";
const BATCH_BUDGET_MS = 20_000; // under the strictest engine budget
// Drains as much of the outbox as fits in the budget.
// Returns true when the queue is empty.
async function drainOutbox() {
const deadline = Date.now() + BATCH_BUDGET_MS;
while (Date.now() < deadline) {
const item = await outbox.peekOldest(); // IndexedDB read
if (!item) return true;
const response = await fetch(item.url, {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": item.id },
body: JSON.stringify(item.body),
});
if (response.status >= 500) throw new Error(`server ${response.status}`); // retry later
await outbox.delete(item.id); // checkpoint after every item
}
return false; // budget exhausted; more work remains
}
self.addEventListener("sync", (event) => {
if (event.tag !== "outbox") return;
event.waitUntil(
drainOutbox().then((done) => {
// Rejecting asks the browser to retry the sync later (Chromium).
if (!done) throw new Error("outbox not empty yet");
}),
);
});
self.addEventListener("fetch", (event) => {
// Opportunistic draining in every engine: piggyback on navigations.
if (event.request.mode === "navigate") {
event.waitUntil(drainOutbox().catch(() => {}));
}
});
The Idempotency-Key header matters. A worker can be terminated after the server processed a request but before outbox.delete() ran, so the server must tolerate replays. Offline-First Data & Sync covers the server side.
Keep-alive pings are an anti-pattern
A page can keep a service worker running by sending a postMessage() every few seconds, because each message is a new event that resets the idle timer. This burns battery, it still hits the per-event ceilings, and it stops working the moment the page closes, which is exactly when background work matters. Firefox also refuses to let one service worker keep another alive this way.
Cross-tab coordination with the Web Locks API¶
The Web Locks API gives every same-origin context a named mutex: windows, dedicated workers, shared workers and service workers (self.navigator.locks is a LockManager on WorkerNavigator). It is the right tool when a tab and the service worker must not do the same thing at once. Typical cases are refreshing an auth token, migrating IndexedDB, compacting a cache, or choosing a single leader tab.
API surface¶
const result = await navigator.locks.request(name, callback);
const result2 = await navigator.locks.request(name, options, callback); // options is optional
// callback(lock) runs once the lock is granted. The lock is released when the
// promise it returns settles (or when it throws). request() resolves or rejects
// with that same outcome.
const { held, pending } = await navigator.locks.query();
// held / pending: arrays of { name, mode, clientId }
| Option | Default | Meaning |
|---|---|---|
mode | "exclusive" | "shared" lets any number of shared holders in at once. An exclusive request waits for all of them |
ifAvailable | false | Don't queue. The callback receives null if the lock is not free right now |
steal | false | Take the lock immediately. The current holder's request() promise rejects with AbortError, and its callback keeps running without the lock. Only valid with mode: "exclusive" |
signal | none | AbortSignal that cancels a queued request (rejects with AbortError) |
Exceptions: NotSupportedError for names starting with -, for steal combined with mode: "shared", for steal combined with ifAvailable, and for signal combined with steal or ifAvailable. SecurityError when no lock manager is available (opaque origins). AbortError for aborted requests. Locks are scoped to the origin's storage bucket, so in third-party contexts they are partitioned by top-level site in the same way as storage (see Privacy & Storage Partitioning).
| Browser | Web Locks (incl. service workers) |
|---|---|
| Chrome / Edge | ✅ 69 |
| Firefox | ✅ 96 |
| Safari (macOS & iOS) | ✅ 15.4 |
Support data as of September 2026. See MDN's LockManager page for live data.
Single-flight token refresh across tabs and the worker¶
Suppose three tabs and the service worker all notice at the same moment that an access token is about to expire. Without coordination you get four refresh calls, and if the server rotates refresh tokens, three of them fail. With a lock, exactly one context refreshes and the others read the result:
sequenceDiagram
participant A as Tab A
participant B as Tab B
participant SW as Service worker
participant L as LockManager
participant S as Server
A->>L: request("auth-refresh")
L-->>A: granted
B->>L: request("auth-refresh")
SW->>L: request("auth-refresh")
A->>S: POST /auth/refresh
S-->>A: new token
A->>A: write token to IndexedDB
A-->>L: release
L-->>B: granted
B->>B: token fresh, skip refresh
B-->>L: release
L-->>SW: granted
SW->>SW: token fresh, skip refresh import { readToken, writeToken } from "./token-store.js"; // IndexedDB wrapper
const LOCK_NAME = "auth-refresh";
const REFRESH_URL = "/auth/refresh"; // the service worker must never intercept this
const isFresh = (token, minValidityMs) =>
token && token.expiresAt - Date.now() > minValidityMs;
export async function getAccessToken({ minValidityMs = 60_000 } = {}) {
const cached = await readToken();
if (isFresh(cached, minValidityMs)) return cached.accessToken;
return navigator.locks.request(LOCK_NAME, async () => {
// Re-check inside the lock: another context may have refreshed while we queued.
const current = await readToken();
if (isFresh(current, minValidityMs)) return current.accessToken;
const response = await fetch(REFRESH_URL, {
method: "POST",
credentials: "same-origin", // refresh token lives in an HttpOnly cookie
});
if (!response.ok) throw new Error(`token refresh failed: ${response.status}`);
const { accessToken, expiresIn } = await response.json();
await writeToken({ accessToken, expiresAt: Date.now() + expiresIn * 1000 });
return accessToken;
});
}
Lock plus fetch interception can deadlock
If a tab holds auth-refresh and calls fetch("/auth/refresh"), that request goes through the service worker. If the worker's fetch handler then calls getAccessToken() for that request, it queues behind the tab's lock, and the tab is waiting for the worker's response. Neither side can make progress until an engine timeout kills the event (up to 5 minutes in Chromium). Exclude lock-protected endpoints from any fetch-handler logic that takes the same lock. The Static Routing API can send them straight to the network without starting the worker at all.
Leader election and finding the leader from the worker¶
A lock that is never released marks its holder. Because query() reports each lock's clientId, which is the same identifier that the Clients API uses, the service worker can find the leader tab and delegate work to it:
navigator.locks.request("leader", () => {
startLeaderDuties(); // e.g. own the WebSocket, poll the server
return new Promise(() => {}); // never settles: held until the tab closes
});
async function notifyLeader(message) {
const { held } = await navigator.locks.query();
const leader = held.find((lock) => lock.name === "leader");
const client = leader && (await self.clients.get(leader.clientId));
if (!client) return false; // no tab open; handle it in the worker instead
client.postMessage(message);
return true;
}
A lock held by the service worker itself is released when the worker terminates. Holding a lock does not extend the worker's lifetime, so wrap lock-holding work in event.waitUntil(), and keep it well inside the budgets above.
IndexedDB from service workers¶
IndexedDB is the service worker's only general-purpose structured storage: localStorage and sessionStorage do not exist in workers. The API is covered on the IndexedDB page. Four details are specific to service workers.
1. Memoize the connection, but expect it to vanish. Opening a connection on every event is slow. Keeping one in a global is fine as a cache, because it disappears with the worker and is rebuilt on the next start.
const DB_NAME = "app";
const DB_VERSION = 3;
let dbPromise = null;
export function openDb() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = request.result;
const tx = request.transaction;
// Additive, version-by-version migrations only (see point 3).
if (event.oldVersion < 1) db.createObjectStore("outbox", { keyPath: "id" });
if (event.oldVersion < 2) tx.objectStore("outbox").createIndex("byCreatedAt", "createdAt");
if (event.oldVersion < 3) db.createObjectStore("kv");
};
// Another connection (an old tab, the previous worker) has not closed yet.
request.onblocked = () => console.warn("[sw] IndexedDB upgrade blocked");
request.onsuccess = () => {
const db = request.result;
// A newer page or worker wants to upgrade: get out of its way.
db.onversionchange = () => {
db.close();
dbPromise = null;
};
// Closed abnormally, e.g. the user cleared site data.
db.onclose = () => {
dbPromise = null;
};
resolve(db);
};
request.onerror = () => {
dbPromise = null;
reject(request.error);
};
});
return dbPromise;
}
2. Transactions auto-commit across non-IndexedDB awaits. A transaction commits as soon as it has no pending requests at the end of a task. Awaiting fetch() or caches.match() inside a transaction lets it commit, and the next request throws TransactionInactiveError:
// ❌ The transaction commits while fetch() is in flight.
async function syncItemBroken(db, id) {
const tx = db.transaction("outbox", "readwrite");
const item = await promisify(tx.objectStore("outbox").get(id));
await fetch("/api/items", { method: "POST", body: JSON.stringify(item) });
tx.objectStore("outbox").delete(id); // TransactionInactiveError
}
// ✅ Read, then do network work, then open a new transaction to write.
async function syncItem(db, id) {
const item = await promisify(db.transaction("outbox").objectStore("outbox").get(id));
if (!item) return;
const response = await fetch("/api/items", { method: "POST", body: JSON.stringify(item) });
if (!response.ok) throw new Error(`upload failed: ${response.status}`);
const tx = db.transaction("outbox", "readwrite");
tx.objectStore("outbox").delete(id);
await new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = tx.onabort = () => reject(tx.error);
});
}
function promisify(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
3. Schema upgrades collide with the waiting period. While a new worker installs, the old worker is still active and still serving pages. If the new worker opens version 3 during install, the old worker's connection receives versionchange and closes. The old worker's next indexedDB.open("app", 2) then fails with VersionError, because you cannot open a database at a lower version than it has. There are three safe options:
- Keep migrations additive, and have old code open the database without a version number (
indexedDB.open("app")opens whatever version exists). - Run the upgrade in
activate, after the old worker is gone, and make pages that still run old code handleVersionErrorby reloading. - Use a new database name for incompatible schemas, and delete the old database in
activate.
4. Wrap the work in waitUntil(). An IndexedDB write that is still in flight when the handler returns can be cut off by termination like any other promise.
WebAssembly in service workers¶
WebAssembly is available in service workers in all engines. It is useful for CPU-bound work that you want to keep off the page's main thread and share across tabs: image and audio codecs, hashing, diffing, compression, search indexes and parsing.
Service-worker-specific rules:
- Precache the
.wasmfile duringinstall. After install, the worker must be able to start offline, and a lazily fetched module is not guaranteed to be in any cache. - Streaming compilation needs
Content-Type: application/wasm.WebAssembly.instantiateStreaming()rejects with aTypeErrorif the response has any other MIME type. AResponsefrom Cache Storage keeps the headers it was stored with, so a misconfigured server poisons the cached copy too. - CSP. If the
sw.jsresponse carries a CSP withscript-src, compiling WebAssembly needs'wasm-unsafe-eval'in it (Chrome 97, Firefox 102, Safari 16). - Compilation cost is paid on every worker start. The instance lives in the global scope and dies with it. Instantiate lazily, on the first event that needs it, never at top level. Top-level instantiation would add latency to every navigation that has to boot the worker.
- Code caching helps. V8 caches the compiled machine code for modules of 128 kB or more that are compiled with
compileStreaming/instantiateStreaming. The V8 team notes this "is enabled for workers and service workers" (v8.dev). Keep the.wasmURL stable per version, because a changed URL means a full recompile. - The synchronous size limit is main-thread only. Chromium rejects synchronous
new WebAssembly.Module()on the main thread for buffers larger than 8 MB. Workers, including service workers, are exempt.
const PRECACHE = "app-precache-v42";
const WASM_URL = "/wasm/codec.2c1f9e.wasm"; // fingerprinted, precached in install
let codecPromise;
function loadCodec() {
codecPromise ??= (async () => {
let response = await caches.match(WASM_URL, { cacheName: PRECACHE });
if (!response) {
response = await fetch(WASM_URL);
if (!response.ok) throw new Error(`wasm fetch failed: ${response.status}`);
}
const imports = { env: { abort: () => { throw new Error("wasm abort"); } } };
try {
const { instance } = await WebAssembly.instantiateStreaming(response.clone(), imports);
return instance.exports;
} catch (error) {
// Wrong MIME type or no streaming support: fall back to buffering.
const bytes = await response.arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes, imports);
return instance.exports;
}
})().catch((error) => {
codecPromise = undefined; // allow a retry on the next event
throw error;
});
return codecPromise;
}
// Assumes the module exports memory, alloc(len), dealloc(ptr, len) and
// transform(ptr, len) -> outLen, which writes its output in place.
// This is the ABI of your own build, not a standard.
export async function transform(input) {
const { memory, alloc, dealloc, transform: run } = await loadCodec();
const ptr = alloc(input.byteLength);
try {
new Uint8Array(memory.buffer, ptr, input.byteLength).set(input);
const outLen = run(ptr, input.byteLength);
// Copy out before freeing: memory.buffer may be detached by a later grow().
return new Uint8Array(memory.buffer, ptr, outLen).slice();
} finally {
dealloc(ptr, input.byteLength);
}
}
Cookie Store API and the cookiechange event¶
document.cookie does not exist in workers. The Cookie Store API gives service workers asynchronous cookie access through self.cookieStore. Through self.registration.cookies it also lets them subscribe to cookie changes that wake the worker as a functional cookiechange event, even when no page is open.
Surface in a service worker¶
| Member | Purpose |
|---|---|
cookieStore.get(name \| options) / getAll(...) | Read script-visible cookies. The default URL is the service worker script's URL, so cookies with a narrower Path need the url option |
cookieStore.set(name, value) / set(options) | Write a cookie. Defaults: path: "/", sameSite: "strict", host-only (no domain), session lifetime, partitioned: false. Cookies written this way are always Secure |
cookieStore.delete(name \| options) | Expire a cookie (match path, domain and partitioned to the original) |
registration.cookies.subscribe([{ name?, url? }]) | Persist a subscription on the registration. url defaults to the scope and must start with it (TypeError otherwise) |
registration.cookies.getSubscriptions() / unsubscribe([...]) | Inspect or remove subscriptions |
cookiechange event (ExtendableCookieChangeEvent) | event.changed and event.deleted: arrays of { name, value, ... } |
Two limits shape every design:
HttpOnlycookies are invisible. The API only exposes script-visible cookies, so your real session cookie, which should beHttpOnly, never appears. The common pattern is a non-secret companion cookie, for examplesession_state=1, that the server sets and clears together with the session.- Returned fields vary. The spec's
CookieListItemguarantees onlynameandvalue. Chromium also returnsdomain,path,expires,secure,sameSiteandpartitioned, and other engines may not.
Purging user data when the session ends¶
const USER_CACHE = "app-user-data";
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Subscriptions persist with the registration; subscribing again is a no-op.
if (self.registration.cookies) {
await self.registration.cookies.subscribe([{ name: "session_state" }]);
}
})(),
);
});
self.addEventListener("cookiechange", (event) => {
const ended = event.deleted.some((cookie) => cookie.name === "session_state");
if (ended) event.waitUntil(purgeUserData());
});
async function purgeUserData() {
await caches.delete(USER_CACHE);
// Also clear per-user IndexedDB stores and tell open tabs.
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
for (const client of windows) client.postMessage({ type: "SESSION_ENDED" });
}
// Fallback for engines without registration.cookies (Safari): check on navigation.
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate" || self.registration.cookies) return;
if (!self.cookieStore) return;
event.waitUntil(
self.cookieStore.get("session_state").then((cookie) => {
if (!cookie) return purgeUserData();
}),
);
});
| Browser | self.cookieStore in SW | registration.cookies + cookiechange |
|---|---|---|
| Chrome / Edge | ✅ 87 | ✅ 87 |
| Firefox | ✅ 140 | ✅ 140 |
| Safari (macOS & iOS) | ✅ 18.4 | ❌ |
Support data as of September 2026. See MDN's Cookie Store API page for live data. The maxAge option of set() is newer (Chrome 145, Firefox 148, Safari 27). Feature-detect it or use expires.
Credentials in service worker fetches¶
A request the worker creates is not the page's request. Its mode, credentials mode, referrer and headers follow the defaults of the Request constructor, and they are easy to get wrong when you rewrite requests.
| How the request is made | mode | credentials | Same-origin cookies | Cross-origin cookies |
|---|---|---|---|---|
fetch(event.request) | As the page made it | As the page made it | Per original | Per original |
Navigation (event.request for a document) | navigate | include | ✅ | ✅ (subject to SameSite) |
fetch(url), new Request(url), cache.add(url) | cors | same-origin | ✅ | ❌ |
fetch(url, { credentials: "include" }) | cors | include | ✅ | ✅. The response needs Access-Control-Allow-Credentials: true and a non-wildcard Access-Control-Allow-Origin |
fetch(url, { mode: "no-cors" }) | no-cors | same-origin | ✅ | ❌. The response is opaque |
<img src> without crossorigin, as seen in the worker | no-cors | include | ✅ | ✅ |
Rules that follow from the Fetch standard's Request constructor:
new Request(event.request, init)with a non-emptyinitdowngrades navigations. Mode"navigate"becomes"same-origin", the reload and history-navigation flags are cleared, andreferrerresets to"client", which means the worker's URL. Passreferrer: event.request.referrerif the server relies on it. Same-origin referrers are kept, and cross-origin ones fall back to"client".new Request(newUrl, event.request)throws for navigations. Using aRequestas theinitargument copiesmode: "navigate", and Chromium throws: "Cannot construct a Request with a RequestInit whose mode member is set as 'navigate'." Build the init object explicitly.event.request.headersis immutable. Copy it:const headers = new Headers(event.request.headers).- No-CORS requests silently drop non-safelisted headers. The
Headersguard forno-corsrequests ignoresAuthorizationand custom headers instead of throwing. - Adding headers to a cross-origin CORS request triggers a preflight. Your API must answer
OPTIONSand list the header inAccess-Control-Allow-Headers. cache.add()/addAll()usecredentials: "same-origin". Precaching a cross-origin URL that needs cookies fails, and precaching a same-origin URL stores whatever the current user's cookies produced. Never precache per-user responses.
The service worker as an edge-like layer¶
A fetch handler sits where a CDN edge function sits, only closer to the user: every request from a controlled page passes through it, and it can answer, rewrite, enrich or reroute that request. The same architecture patterns apply, with two differences that decide every design:
- The worker is not always there. On the first visit, after a hard reload (Shift-reload bypasses the worker), when storage is cleared, in unsupported WebViews, and for crawlers, the server sees the raw request. The server must produce a correct response on its own, so the worker can only enhance.
- The worker is one thread per registration. CPU-heavy work in one handler delays every other request that worker is serving. Anything expensive should be cached, time-boxed, or moved to the page or a dedicated worker.
flowchart LR
R["Request from page"] --> Router{"Route"}
Router -->|"legacy path"| RW["Rewrite URL"]
Router -->|"experiment page"| AB["Pick variant"]
Router -->|"/api/"| GW["Gateway: auth, dedupe, timeout"]
Router -->|"/thumbs/"| IMG["Transcode image"]
Router -->|"/collect"| AN["Queue analytics"]
Router -->|"anything else"| NET["Network or cache strategy"]
RW --> NET
AB --> NET
GW --> NET
IMG --> NET
AN --> NET
NET -->|"any failure"| FO["Fail open: fetch(event.request)"] Keep one fetch listener that routes explicitly. With several listeners, the first one to call respondWith() wins, and the order depends on registration order in code, which is fragile in large codebases.
const routes = [
{ match: (url, req) => req.mode === "navigate" && LEGACY.has(url.pathname), handle: rewriteLegacy },
{ match: (url, req) => req.mode === "navigate" && url.pathname === "/checkout", handle: experimentPage },
{ match: (url) => url.pathname.startsWith("/api/") && !url.pathname.startsWith("/api/auth/"), handle: apiGateway },
{ match: (url) => url.pathname.startsWith("/thumbs/"), handle: thumbnail },
{ match: (url, req) => url.pathname === "/analytics/collect" && req.method === "POST", handle: collect },
];
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin) return; // cross-origin: let the browser handle it
const route = routes.find((r) => r.match(url, event.request));
if (!route) return; // no respondWith: default network handling
event.respondWith(
route.handle(event, url).catch((error) => {
console.warn("[sw] route failed, falling back to network", url.pathname, error);
return fetch(event.request);
}),
);
});
For paths that should never touch the worker at all, such as auth endpoints, large downloads and streaming media, the Static Routing API lets you declare network routes at install time, so the browser skips starting the worker for them.
Request rewriting¶
const LEGACY = new Map([
["/blog.php", "/blog/"],
["/about-us.html", "/about/"],
]);
async function rewriteLegacy(event, url) {
const target = new URL(LEGACY.get(url.pathname), url);
target.search = url.search;
// Option 1: an explicit redirect updates the address bar and history.
return Response.redirect(target.href, 301);
}
Rewriting without a redirect, meaning you serve /blog/ for a request to /blog.php, keeps the requested URL in the address bar, and the document resolves relative URLs against that requested URL. Use root-relative URLs in the served HTML, or the page's relative links and assets break. Construct the upstream request explicitly. Never pass event.request as the init argument of a navigation request (see Credentials in service worker fetches).
A/B testing at the worker¶
A worker-side experiment can swap a precached variant shell instantly, with no flash of the control version. To keep the server, analytics and the worker in agreement, let the server assign the bucket in a cookie that scripts can read, and have the worker read it:
const EXPERIMENT = "checkout_v2";
const VARIANT_URL = { control: "/checkout/", treatment: "/checkout/variant-b/" };
async function bucketFor(name) {
// Server-assigned, script-visible cookie, e.g. "exp_checkout_v2=treatment".
const cookie = self.cookieStore ? await self.cookieStore.get(`exp_${name}`) : null;
return cookie?.value === "treatment" ? "treatment" : "control";
}
async function experimentPage(event) {
const bucket = await bucketFor(EXPERIMENT);
const upstream = new Request(VARIANT_URL[bucket], {
credentials: "include",
headers: { "X-Experiment": `${EXPERIMENT}=${bucket}` },
});
// Cache variants under their own URLs, never under /checkout itself.
const cached = await caches.match(upstream);
return cached ?? fetch(upstream);
}
The response is fetched from a different URL but is not redirected (its URL list has one entry), so it is valid for a navigation. If the variant URL redirects on the server, the navigation fails, because navigations use redirect mode "manual". See the redirected-response entry in Pitfalls & Anti-Patterns.
Image transcoding and thumbnails¶
createImageBitmap() and OffscreenCanvas are exposed to workers, so a worker can generate derived images from cached originals: thumbnails for an offline gallery, or downscaled previews of user uploads. Feature-detect both, because exposure in service workers specifically varies more than exposure in dedicated workers:
const THUMBS = "app-thumbs";
const THUMB_RE = /^\/thumbs\/(\d{2,4})(\/.+)$/; // /thumbs/320/photos/cat.jpg
const canTranscode =
typeof self.createImageBitmap === "function" && typeof self.OffscreenCanvas === "function";
async function thumbnail(event, url) {
const match = THUMB_RE.exec(url.pathname);
if (!match || !canTranscode) return fetch(event.request);
const width = Math.min(Number(match[1]), 2048);
const sourcePath = match[2];
const cache = await caches.open(THUMBS);
const hit = await cache.match(event.request);
if (hit) return hit;
const source = (await caches.match(sourcePath)) ?? (await fetch(sourcePath));
if (!source.ok) return source;
// Decoding is asynchronous; resizeWidth alone preserves the aspect ratio.
const bitmap = await createImageBitmap(await source.blob(), {
resizeWidth: width,
resizeQuality: "high",
});
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
canvas.getContext("2d").drawImage(bitmap, 0, 0);
bitmap.close();
// Unsupported types fall back to PNG, so trust blob.type, not the request.
const blob = await canvas.convertToBlob({ type: "image/webp", quality: 0.8 });
const response = new Response(blob, {
headers: { "Content-Type": blob.type, "Content-Length": String(blob.size) },
});
event.waitUntil(cache.put(event.request, response.clone()));
return response;
}
Cap the number of cached thumbnails (see Caching Strategies), and don't transcode on a device that is already busy rendering. Server-side or CDN image resizing is still the default. Use this for offline-only or user-generated content.
An API gateway in the worker¶
import { getAccessToken } from "./shared/auth-token.js"; // module worker or bundled
const inflight = new Map(); // GET de-duplication; lives only as long as the worker
const NULL_BODY_STATUS = new Set([204, 205, 304]); // statuses that forbid a body
async function apiGateway(event) {
const { request } = event;
const headers = new Headers(request.headers); // event.request.headers is immutable
const token = await getAccessToken().catch(() => null);
if (token) headers.set("Authorization", `Bearer ${token}`);
const init = {
method: request.method,
headers,
credentials: "same-origin",
cache: request.cache,
redirect: request.redirect,
referrer: request.referrer,
signal: AbortSignal.timeout(10_000), // Chrome 124+ throws TimeoutError; earlier AbortError
};
if (request.method !== "GET" && request.method !== "HEAD") {
init.body = await request.arrayBuffer(); // buffered: small JSON bodies only
return fetch(request.url, init);
}
// Coalesce identical concurrent GETs from several tabs into one network call.
const key = `${request.url}|${token ?? "anon"}`;
let snapshot = inflight.get(key);
if (!snapshot) {
snapshot = fetch(request.url, init)
.then(async (response) => ({
body: await response.arrayBuffer(),
status: response.status,
statusText: response.statusText,
headers: [...response.headers],
}))
.finally(() => inflight.delete(key));
inflight.set(key, snapshot);
}
const { body, ...meta } = await snapshot;
// Every caller gets its own Response. A null-body status (204, 205, 304) must
// be constructed with a null body, or the Response constructor throws.
return new Response(NULL_BODY_STATUS.has(meta.status) ? null : body, meta);
}
The coalescing buffers the body once and builds a fresh Response per caller. Sharing one Response through clone() would make the stream tee buffer everything for readers that never consume it. Keep gateway logic stateless, apart from caches like inflight, because the map disappears whenever the worker stops.
Offline analytics¶
Analytics hits sent while offline are normally lost. A worker can queue them and replay them later, adding how long each hit waited so that event times stay correct. The pattern works with any first-party collection endpoint. For measurement strategy, see Analytics for PWAs.
// IndexedDB-backed store (bundled): add(record), all() -> records with id, delete(id).
import { analyticsQueue } from "./sw-analytics-queue.js";
async function collect(event) {
const body = await event.request.clone().text();
try {
const response = await fetch(event.request);
if (response.status < 500) return response; // delivered, or a client error to surface
throw new Error(`collector ${response.status}`);
} catch {
await analyticsQueue.add({ body, queuedAt: Date.now() }); // IndexedDB store
if ("sync" in self.registration) {
await self.registration.sync.register("analytics").catch(() => {});
}
return new Response(null, { status: 202, statusText: "Queued" });
}
}
async function replayAnalytics() {
for (const hit of await analyticsQueue.all()) {
const payload = JSON.parse(hit.body);
payload.queue_time_ms = Date.now() - hit.queuedAt; // let the backend back-date the hit
const response = await fetch("/analytics/collect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (response.status >= 500) throw new Error("collector unavailable"); // retry later
await analyticsQueue.delete(hit.id);
}
}
self.addEventListener("sync", (event) => {
if (event.tag === "analytics") event.waitUntil(replayAnalytics());
});
Have the page send hits with a normal fetch() to your own origin, so they pass through the worker. Drop hits older than your backend accepts, and cap the queue size.
self.serviceWorker: the worker's own ServiceWorker object¶
ServiceWorkerGlobalScope.serviceWorker returns the ServiceWorker object for the running worker. It lets code inside the worker read its own state ("parsed", "installing", "installed", "activating", "activated", "redundant") and scriptURL, and listen for statechange. It is useful for diagnostics and for code that must behave differently while the worker is still waiting:
const VERSION = "2026-09-25.1"; // injected by the build in practice
const me = self.serviceWorker; // undefined where unsupported (Firefox)
me?.addEventListener("statechange", () => {
console.log(`[sw ${VERSION}] state -> ${me.state}`);
});
// A tiny status endpoint for debugging which worker answers a page.
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.pathname !== "/__sw/status") return;
event.respondWith(
(async () => {
const clients = await self.clients.matchAll({ includeUncontrolled: true });
const body = {
version: VERSION,
state: me?.state ?? "unknown",
scriptURL: me?.scriptURL ?? self.location.href,
scope: self.registration.scope,
clients: clients.length,
};
return new Response(JSON.stringify(body), {
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
})(),
);
});
| Browser | self.serviceWorker |
|---|---|
| Chrome / Edge | ✅ 79 |
| Safari (macOS & iOS) | ✅ 15.4 |
| Firefox | ❌ |
Support data as of September 2026. See MDN's ServiceWorkerGlobalScope page for live data.
clients.openWindow() constraints¶
clients.openWindow(url) opens a new top-level browsing context from the worker. The spec's steps explain every failure you will see:
urlis parsed against the worker's base URL. A parse failure rejects withTypeError.about:blankrejects withTypeError.- "If no Window in this origin has transient activation", the call rejects with
InvalidAccessError. In practice, engines grant this power only while handling a user-initiated functional event. - The window opens, and the promise resolves with a
WindowClientonly if the new document has the worker's storage key (same origin). For a cross-origin URL the window still opens, but the promise resolves withnull.
Engine rules layered on top:
| Engine | When openWindow() / focus() is allowed |
|---|---|
| Chromium | During notificationclick, paymentrequest and backgroundfetchclick. One window interaction per event (an openWindow() or a focus()). When you use waitUntil(), it must happen within 10 s. Error: "Not allowed to open a window." In an installed PWA, the URL may open in the app's existing window (Chrome for Android since 51; MDN notes this now also works on Windows) |
| Firefox | Only as the result of a notification click, within 1 s on desktop and 5 s on Android |
| Safari | Inside notificationclick, wherever Web Push is available (see Web Push on iOS & Safari) |
Because Chromium allows exactly one interaction, and Firefox allows it for about a second, do the minimum async work first, and use navigate(), which needs no activation, before spending the interaction:
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = new URL(event.notification.data?.url ?? "/", self.location.origin).href;
event.waitUntil(openOrFocus(url));
});
async function openOrFocus(url) {
// Controlled windows only: navigate() rejects for clients this worker doesn't control.
const windows = await self.clients.matchAll({ type: "window" }); // most recently focused first
const exact = windows.find((client) => client.url === url);
if (exact) return exact.focus(); // spends the window interaction
const existing = windows[0];
if (existing && typeof existing.navigate === "function") {
try {
const navigated = await existing.navigate(url); // null if it ended up cross-origin
if (navigated) return navigated.focus();
} catch {
// navigate() unsupported (Safari < 16) or rejected: fall through
}
}
return self.clients.openWindow(url);
}
For notification payload design and click routing, see Notifications API and Push Notifications.
Service workers in embedded WebViews and wrappers¶
Your PWA may run inside someone else's app: a social network's in-app browser, your own store wrapper, or a hybrid framework shell. Service worker support there depends on the embedding API, not on the browser brand.
iOS and iPadOS: WKWebView¶
On iOS-family platforms, WebKit disables service workers in WKWebView unless one of these is true. This is from WKWebView.mm:
- the app has the
com.apple.developer.web-browserentitlement (apps approved as default browsers), or thecom.apple.developer.WebKit.ServiceWorkersentitlement, or - the web view's configuration sets
limitsNavigationsToAppBoundDomains = true, which means the app has opted into App-Bound Domains.
App-Bound Domains (iOS 14+) are declared in Info.plist under WKAppBoundDomains, with at most 10 domains. The opt-in has side effects: navigations outside those domains fail, and JavaScript injection, custom style sheets, cookie manipulation and message handlers are denied for non-app-bound content.
<key>WKAppBoundDomains</key>
<array>
<string>app.example.com</string>
<string>static.example.com</string>
</array>
import WebKit
func makeWebView() -> WKWebView {
let configuration = WKWebViewConfiguration()
// Required for service workers in WKWebView on iOS without special entitlements.
configuration.limitsNavigationsToAppBoundDomains = true
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.load(URLRequest(url: URL(string: "https://app.example.com/")!))
return webView
}
Treat this as best effort. In February 2025, an Apple frameworks engineer answered in an Apple Developer Forums thread that "There's no supported way for you to explicitly support service workers in iOS WKWebView with the APIs currently available." Two practical consequences follow. In-app browsers on iOS generally run your site without a service worker, so the site must work fully without one. And if you ship your own iOS wrapper, test offline behavior on the real configuration. The check is compiled only for iOS-family platforms. It doesn't apply to WKWebView in native macOS (AppKit) apps, but Mac Catalyst apps are built for the iOS family and are affected. See iOS & iPadOS and Publishing to App Stores.
Android WebView¶
Android WebView is Chromium, and service workers work there on https: origins. One integration detail catches most wrapper apps: requests made by a service worker do not go through WebViewClient.shouldInterceptRequest(). To observe or serve them, register a ServiceWorkerClient through ServiceWorkerController (framework API level 24+), or the AndroidX equivalent, which is gated by a runtime feature check:
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import androidx.webkit.ServiceWorkerClientCompat
import androidx.webkit.ServiceWorkerControllerCompat
import androidx.webkit.WebViewFeature
fun configureServiceWorkers() {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.SERVICE_WORKER_BASIC_USAGE)) return
val controller = ServiceWorkerControllerCompat.getInstance()
controller.setServiceWorkerClient(object : ServiceWorkerClientCompat() {
override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? {
// Requests made by service workers arrive here, not in WebViewClient.
// Return null to let the network (or the worker's own cache logic) handle them.
return null
}
})
if (WebViewFeature.isFeatureSupported(WebViewFeature.SERVICE_WORKER_CACHE_MODE)) {
controller.serviceWorkerWebSettings.cacheMode = WebSettings.LOAD_DEFAULT
}
}
ServiceWorkerWebSettings also controls allowContentAccess, allowFileAccess and blockNetworkLoads for all service workers in the app. They are separate from the per-WebView WebSettings. Registrations and caches live in the app's private data directory and are shared by every WebView in the process.
Other wrappers¶
- Trusted Web Activity runs your PWA in the user's browser (usually Chrome), so service workers, push and storage behave exactly as they do in the browser. See Trusted Web Activity.
- Hybrid shells that serve bundled files from a custom scheme (
capacitor://,app://and similar) cannot register service workers for those pages:register()accepts onlyhttp:andhttps:script URLs and rejects anything else with aTypeError. Serve the app from anhttps:origin inside the shell if you need a worker. - Electron supports service workers on
http(s)and on custom protocols that are registered as privileged withallowServiceWorkers: true.
Multiple service workers per origin¶
An origin can have any number of registrations, each keyed by its scope URL (and by storage key, so partitioned third-party contexts get separate sets). The rules for which worker handles what are strict, and they surprise people:
- Navigation matching is the longest string prefix. The spec's Match Service Worker Registration picks the longest registered scope that the client URL starts with. The match is prefix-based, not path-segment-based: scope
/appmatches/application/. Always end scopes with/. - A client has one controller, fixed at creation. A page at
/docs/controlled by the root worker stays with the root worker. All of its subresource requests go to the root worker, including afetch("/app/api/x"). Subresources are routed by the client's controller, never by the subresource's URL. - Everything else is shared. Cache Storage (
caches.keys()lists every cache of the origin), IndexedDB, cookies, Web Locks andBroadcastChannelare per origin, not per registration. clients.claim()only claims clients that match the claiming registration. A root worker cannot take over pages under/app/while an/app/registration exists.
flowchart TD
N["Navigation to /app/settings"] --> M{"Longest scope prefix"}
M -->|"/app/ registered"| A["/app/ worker controls the page"]
M -->|"only / registered"| R["Root worker controls the page"]
A --> S["Every fetch from this page goes to the /app/ worker"]
R --> T["Every fetch from this page goes to the root worker"] Design options¶
| Design | When it fits | Costs |
|---|---|---|
| One root worker with internal routing per section | Almost always, including micro-frontends that you can build together | Coordinated releases, one bundle to own |
One worker per sub-app (/app/, /admin/, /docs/) | Independent teams and deploy cadences, sections with very different caching needs | Duplicate code, shared-storage collisions, cross-section navigations switch controllers |
A narrow-scope worker for one feature (for example /editor/ only) | Adding offline support to one area of a large legacy site | The rest of the site gets no worker, and a later root worker cannot claim /editor/ pages |
If you run several workers, namespace every shared resource, and clean up only your own namespace:
const NS = "app"; // unique per registration
const VERSION = "2026-09-25.1";
const CURRENT = new Set([`${NS}:precache:${VERSION}`, `${NS}:runtime`]);
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
// ❌ keys.filter((k) => !CURRENT.has(k)) would also delete the
// root worker's and /admin/ worker's caches.
.filter((key) => key.startsWith(`${NS}:`) && !CURRENT.has(key))
.map((key) => caches.delete(key)),
);
})(),
);
});
To remove a section's worker later, ship a kill-switch script at the same URL, as described in Pitfalls & Anti-Patterns, or call unregister() on the registration returned by navigator.serviceWorker.getRegistration("/app/"). Unregistering one scope never affects the others.
Browser support summary¶
| Feature | Chrome / Edge | Firefox | Safari (macOS / iOS) |
|---|---|---|---|
| Module service workers | ✅ 91 | ✅ 147 | ✅ 15 |
updateViaCache | ✅ 68 | ✅ 57 | ✅ 11.1 |
| Web Locks in service workers | ✅ 69 | ✅ 96 | ✅ 15.4 |
self.cookieStore | ✅ 87 | ✅ 140 | ✅ 18.4 |
registration.cookies / cookiechange | ✅ 87 | ✅ 140 | ❌ |
self.serviceWorker | ✅ 79 | ❌ | ✅ 15.4 |
WindowClient.navigate() | ✅ 49 | ✅ 50 | ✅ 16 ⚠️ |
CSP 'wasm-unsafe-eval' | ✅ 97 | ✅ 102 | ✅ 16 |
⚠️ Safari 11.1 to 15.x exposed navigate(), but it always threw NotSupportedError.
Support data as of September 2026. Check MDN and caniuse for live data.
Debugging advanced setups¶
- Which worker answered? Add a status route such as
/__sw/status(shown above), and include a build version in every log line. - Chromium:
chrome://serviceworker-internalslists every registration with its version IDs, running status and console output, and lets you start, stop, inspect and unregister workers. In DevTools, the Application › Service workers panel shows the active, waiting and installing workers per origin. Browser DevTools walks through both. - Firefox:
about:debugging#/runtime/this-firefoxlists workers with Start, Inspect and Unregister buttons. Grace-timeout terminations are logged to the browser console. - Safari: Develop › Service Workers opens a Web Inspector attached to a specific worker. Attaching the inspector keeps the worker alive (WebKit treats inspected workers as non-terminable), so reproduce lifetime bugs with the inspector detached.
- Termination bugs only appear after the worker has been stopped. In Chromium, click Stop in
chrome://serviceworker-internalsbetween steps to verify that nothing depends on globals. Automated Testing shows how to do this in end-to-end tests.
Further reading¶
On this site
- Service Worker Lifecycle
- Updating Service Workers
- Handling Fetch Events
- Messaging & the Clients API
- Static Routing API
- Pitfalls & Anti-Patterns
- IndexedDB
- Service Worker Security
External references
- Service Workers specification (W3C Editor's Draft)
- MDN:
ServiceWorkerContainer.register() - web.dev: ES modules in service workers
- Chrome for Developers: Fresher service workers, by default
- Web Locks API specification
- Cookie Store API standard
- WebKit blog: App-Bound Domains
- Android:
ServiceWorkerController - V8: WebAssembly code caching
- esbuild API