Skip to content

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. Static import works, but import() rejects with a TypeError, top-level await makes registration fail, and importScripts() throws.
  • importScripts() fetches new URLs only while the worker is parsed or installing. After that, only URLs already stored in the worker's script resource map can be imported. Chromium throws a NetworkError for 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 WKWebView disables service workers unless the app uses App-Bound Domains or holds the web-browser entitlement, and Android WebView needs ServiceWorkerController to 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-sw.js
// 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;
  }
}
  1. Service workers exist only in secure contexts (https: or localhost). On http: pages, navigator.serviceWorker is undefined.

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:

DevTools console (Chromium)
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:

sw.js (module)
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-cache whenever updateViaCache is 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:

register-sw.js
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:

  1. If the worker's state is not "parsed" or "installing", return map[url] if it exists, or a network error otherwise.
  2. If map[url] exists, use it. No network request is made.
  3. Otherwise, fetch it. The fetch bypasses service workers, and uses no-cache when updateViaCache is "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:

DevTools console (Chromium)
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:

sw.js (classic)
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 with Cache-Control: max-age=31536000 and an unchanged URL is effectively frozen for as long as the HTTP cache keeps it. Either fingerprint import URLs, or register with updateViaCache: "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.js response, not from the page (spec §6.2). Imported scripts have the script destination, so the worker's script-src (falling back to default-src) governs what importScripts() may load. The page's worker-src governs 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.js itself, 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:

  1. Output format iife (or esm only if you register with type: "module").
  2. No code splitting. A split chunk becomes an import() (banned) or an extra file the worker cannot fetch after install.
  3. No top-level await in the output when the format is ESM (see above).
  4. Stable output name (sw.js), served from the scope root. Never content-hash the worker's own file name. Pitfalls & Anti-Patterns explains why.
  5. Replace process.env.NODE_ENV. Libraries such as Workbox branch on it, and service workers have no process.
  6. Browser/worker conditions. Resolve packages with the browser or worker export conditions, and make sure nothing references window or document.

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.

scripts/build-sw.mjs
// 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

rollup.sw.config.mjs
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/:

vite.sw.config.ts
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",
    },
  },
});
package.json (scripts)
{
  "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":

tsconfig.sw.json
{
  "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:

src/sw/index.ts
/// <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 is self.__WB_MANIFEST. Declare it as declare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: Array<PrecacheEntry | string> }, importing PrecacheEntry from workbox-precaching.
  • Some newer or engine-specific APIs (Background Sync's SyncEvent, Periodic Sync, Background Fetch, or cookieStore depending on your TypeScript version) may be missing from the bundled lib files. Declare the pieces you use in a local sw-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 postMessage are 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:

  1. respondWith() must be called synchronously during dispatch of the fetch event; a later call throws InvalidStateError. 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 throws InvalidStateError; in Chromium the message is "The event handler is already finished and no extend lifetime promises are outstanding."
  2. Pending promises delay termination but never prevent it. Every engine has a hard ceiling.
  3. 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:

Browser console (Firefox)
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's fetch).
  • 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 sync promise makes Chromium schedule a retry, and event.lastChance tells 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.
sw.js: time-boxed queue draining
// 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

LockManager API shape
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
shared/auth-token.js (imported by pages and the service worker)
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:

page.js: become leader while this tab is open
navigator.locks.request("leader", () => {
  startLeaderDuties(); // e.g. own the WebSocket, poll the server
  return new Promise(() => {}); // never settles: held until the tab closes
});
sw.js: delegate to the leader tab if there is one
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.

sw-db.js
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:

sw.js
// ❌ 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 handle VersionError by 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 .wasm file during install. 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 a TypeError if the response has any other MIME type. A Response from Cache Storage keeps the headers it was stored with, so a misconfigured server poisons the cached copy too.
  • CSP. If the sw.js response carries a CSP with script-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 .wasm URL 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.
sw.js: lazily instantiated codec
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);
  }
}

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:

  • HttpOnly cookies are invisible. The API only exposes script-visible cookies, so your real session cookie, which should be HttpOnly, never appears. The common pattern is a non-secret companion cookie, for example session_state=1, that the server sets and clears together with the session.
  • Returned fields vary. The spec's CookieListItem guarantees only name and value. Chromium also returns domain, path, expires, secure, sameSite and partitioned, and other engines may not.

Purging user data when the session ends

sw.js
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-empty init downgrades navigations. Mode "navigate" becomes "same-origin", the reload and history-navigation flags are cleared, and referrer resets to "client", which means the worker's URL. Pass referrer: event.request.referrer if 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 a Request as the init argument copies mode: "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.headers is immutable. Copy it: const headers = new Headers(event.request.headers).
  • No-CORS requests silently drop non-safelisted headers. The Headers guard for no-cors requests ignores Authorization and custom headers instead of throwing.
  • Adding headers to a cross-origin CORS request triggers a preflight. Your API must answer OPTIONS and list the header in Access-Control-Allow-Headers.
  • cache.add()/addAll() use credentials: "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.

sw.js: router skeleton
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

sw.js
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:

sw.js
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:

sw.js
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

sw.js
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.

sw.js
// 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:

sw.js
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:

  1. url is parsed against the worker's base URL. A parse failure rejects with TypeError.
  2. about:blank rejects with TypeError.
  3. "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.
  4. The window opens, and the promise resolves with a WindowClient only 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 with null.

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:

sw.js
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-browser entitlement (apps approved as default browsers), or the com.apple.developer.WebKit.ServiceWorkers entitlement, 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.

Info.plist
<key>WKAppBoundDomains</key>
<array>
  <string>app.example.com</string>
  <string>static.example.com</string>
</array>
WebViewController.swift
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:

MainActivity.kt
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 only http: and https: script URLs and rejects anything else with a TypeError. Serve the app from an https: 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 with allowServiceWorkers: 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 /app matches /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 a fetch("/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 and BroadcastChannel are 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:

/app/sw.js
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-internals lists 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-firefox lists 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-internals between 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

External references