This page is part of the PWA Offline Recovery and Service-Worker Resilience section, which covers how a service worker’s independent execution context reshapes every failure-handling assumption your team carries over from the main thread.

The problem: errors that vanish into a separate global scope #

A service worker does not run on the page. It runs in its own thread-like context — a ServiceWorkerGlobalScope with no window, no document, and no DOM. Your window.onerror handler, your React error boundary, your Sentry browser SDK initialized against window — none of them see a single exception thrown inside install, activate, fetch, or a background sync callback. The worker can crash silently, over and over, while every dashboard you own reports a clean error rate.

The failure mode that makes this dangerous is the fetch event handler. event.respondWith() expects a Promise<Response>. If the function passed to it throws before that promise resolves — a null reference in a cache-matching branch, a malformed URL passed to new Request(), an unhandled rejection inside an await chain — the browser has no Response to hand back to the navigation that triggered it. It does not fall back to the network. It renders its own generic offline error page for that request, and because a single service worker controls every navigation in its scope, the effect is not one broken page — it is every page under that scope failing to load, simultaneously, with zero telemetry, until a user manually unregisters the worker or clears storage.

User-visible consequence: a blank screen or the browser’s native “no internet” interstitial on a site the user has open Wi-Fi access to, with no error surfaced anywhere your monitoring stack can see it, and no reproduction path because the exception depends on cache state that differs between the affected user’s browser and yours.

Prerequisites #


Core implementation #

The listeners below are the only hook the worker has into its own exceptions. self.addEventListener('error', …) catches synchronous throws and uncaught runtime errors anywhere in the worker’s scope; unhandledrejection catches promise rejections that were never .catch()-ed. Neither of these substitutes for guarding the fetch handler directly — a caught-and-reported error still needs the handler to resolve a Response, or the navigation fails regardless of whether telemetry captured it.

/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;

// ─── Structured payload shared with the page-side bridge ─────────────────
interface SwErrorPayload {
  source: 'sw-error' | 'sw-unhandledrejection' | 'sw-fetch-handler';
  message: string;
  stack?: string;
  filename?: string;
  lineno?: number;
  colno?: number;
  swVersion: string;
  timestamp: number;
}

const SW_VERSION = 'v14'; // bump every deploy so errors bucket by release

// ─── Forward to every open client; queue if none are listening ───────────
async function reportToClients(payload: SwErrorPayload): Promise<void> {
  try {
    const clientList = await self.clients.matchAll({ includeUncontrolled: true });
    if (clientList.length === 0) {
      // No page is open to receive this — hand off to the IndexedDB queue
      await queueErrorForLaterDelivery(payload);
      return;
    }
    for (const client of clientList) {
      client.postMessage({ type: 'SW_ERROR', payload });
    }
  } catch {
    // Reporting logic must never itself throw inside an error handler
  }
}

// ─── Global scope listeners: the ONLY visibility into SW exceptions ──────
self.addEventListener('error', (event: ErrorEvent) => {
  void reportToClients({
    source: 'sw-error',
    message: event.message,
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
    swVersion: SW_VERSION,
    timestamp: Date.now(),
  });
});

self.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
  const reason = event.reason;
  void reportToClients({
    source: 'sw-unhandledrejection',
    message: reason instanceof Error ? reason.message : String(reason),
    stack: reason instanceof Error ? reason.stack : undefined,
    swVersion: SW_VERSION,
    timestamp: Date.now(),
  });
  event.preventDefault(); // we've captured it; suppress the default console noise
});

// ─── Guarded fetch handler: MUST resolve a Response, always ──────────────
self.addEventListener('fetch', (event: FetchEvent) => {
  event.respondWith(
    (async () => {
      try {
        return await handleFetch(event.request);
      } catch (err) {
        // A throwing fetch handler fails EVERY navigation it controls —
        // catch unconditionally, report, then degrade instead of crashing.
        void reportToClients({
          source: 'sw-fetch-handler',
          message: err instanceof Error ? err.message : String(err),
          stack: err instanceof Error ? err.stack : undefined,
          swVersion: SW_VERSION,
          timestamp: Date.now(),
        });
        return fetch(event.request).catch(
          () => new Response('Offline', { status: 503, statusText: 'Service Unavailable' })
        );
      }
    })()
  );
});

async function handleFetch(request: Request): Promise<Response> {
  const cached = await caches.match(request);
  if (cached) return cached;
  return fetch(request);
}

// Full implementation in "Edge cases and gotchas" below
async function queueErrorForLaterDelivery(payload: SwErrorPayload): Promise<void> {
  const db = await openTelemetryDb();
  const tx = db.transaction('pending-errors', 'readwrite');
  tx.objectStore('pending-errors').add(payload);
}

declare function openTelemetryDb(): Promise<IDBDatabase>;

Architecture note #

self in a service worker is a ServiceWorkerGlobalScope, not the Window your framework’s error boundary was designed around — there is no synchronous access to document, localStorage, or any app-level React/Vue context, so the only channel back to the page is asynchronous message passing. That asymmetry is why the fetch handler’s try/catch is not optional decoration: a rejected or throwing respondWith promise has no fallback the platform will supply for you, unlike an uncaught render error in a component tree, which at least stops at the nearest boundary. Here, “nearest boundary” is the entire navigation.

The postMessage bridge exists because reportToClients cannot call fetch to your telemetry endpoint directly and reliably — a worker can be terminated by the browser mid-request when idle, and keepalive fetches from a worker context are less consistently honored across browsers than sendBeacon from a page. Handing the payload to an open client and letting the page call sendBeacon puts the delivery guarantee where the platform actually provides one. Sampling and deduplication belong on the client side of that bridge, not inside the worker, because the worker has no cheap way to know how many other tabs are about to report the identical fingerprint in the same second — the page-level listener can coordinate that with BroadcastChannel or shared buffering far more easily than the worker can across its clients.matchAll() results.

Lifecycle and scheduling compound the problem further. The browser can terminate an idle service worker at any point between events to reclaim memory, then spin up a fresh instance the next time a fetch or message event needs handling — module-level variables like the in-memory retryQueue pattern used elsewhere on this site do not survive that recycling inside a worker the way they do on a long-lived page. SW_VERSION and any counters your error listeners rely on must either be recomputed cheaply on each activation or persisted to IndexedDB, because there is no guarantee the same worker instance that threw the exception is still resident by the time telemetry is flushed. This is also why the guarded fetch handler cannot assume a “warm” worker: the very first event dispatched to a freshly spun-up instance can be the one that throws, before any of the worker’s own bootstrapping logic has had a chance to run.


Error routing: from a thrown exception to the server #

Service worker error routing: thrown exception to server telemetry A flowchart with two paths. When a client is open: SW fetch handler throws, self error/unhandledrejection listeners capture it, clients.matchAll and postMessage deliver it to the page, the page's message listener receives it, and sendBeacon ships it to the telemetry server. When no client is open: the listener queues the payload to IndexedDB, which is flushed on the next activate or fetch event and eventually reaches the same sendBeacon step. SW fetch / install / activate handler throws or rejects self error / unhandledrejection reportToClients() listeners clients open clients.matchAll() client.postMessage() Page: message listener + buffer sendBeacon() sampled + deduped → telemetry server no clients open IndexedDB queue pending-errors store Flush on next activate / fetch event client open, delivered immediately no client, queued and flushed later

Edge cases and gotchas #

Failure mode Symptom Mitigation
No client open when the error fires (push, background sync) clients.matchAll() returns an empty array; payload is dropped Queue to IndexedDB and flush on the next activate or fetch event
Minified worker stack traces stack shows sw.js:1:48213 with no readable frame Emit a source map for the worker bundle specifically and resolve it server-side against the deployed swVersion
PII in request URLs or error messages Query strings, auth tokens, or user identifiers land verbatim in telemetry Redact known-sensitive query params and strip Authorization-shaped substrings before postMessage, not after
Error storms during a bad deploy Thousands of identical unhandledrejection events fire within seconds across every controlled tab Fingerprint and sample on the client before sendBeacon; never beacon once per raw event
event.preventDefault() omitted on unhandledrejection Duplicate reports: your telemetry AND the browser’s default unhandled-rejection console warning Call preventDefault() once the payload has been captured and forwarded

The IndexedDB fallback referenced in the core implementation needs a matching flush routine, since a queued payload is only useful if something eventually drains it:

async function flushPendingErrors(): Promise<void> {
  const db = await openTelemetryDb();
  const tx = db.transaction('pending-errors', 'readwrite');
  const store = tx.objectStore('pending-errors');
  const all = await requestToPromise<SwErrorPayload[]>(store.getAll());

  const clientList = await self.clients.matchAll({ includeUncontrolled: true });
  if (clientList.length === 0 || all.length === 0) return; // still nothing to deliver to

  for (const payload of all) {
    clientList[0].postMessage({ type: 'SW_ERROR', payload, replayed: true });
  }
  await requestToPromise(store.clear());
}

self.addEventListener('activate', (event) => {
  event.waitUntil(flushPendingErrors());
});

function requestToPromise<T>(req: IDBRequest): Promise<T> {
  return new Promise((resolve, reject) => {
    req.onsuccess = () => resolve(req.result as T);
    req.onerror = () => reject(req.error);
  });
}

Redaction belongs inside reportToClients, before the payload ever leaves the worker — scrubbing on the server means the raw string already transited a postMessage channel and, in the queued path, sat in IndexedDB unredacted in the interim.


Advanced variant: client-side batching, sampling, and dedupe #

The worker’s job ends at postMessage. Everything about controlling telemetry volume — sampling, deduplication, batching — belongs on the page, where you can see every message arriving from every registration and coordinate a single outbound sendBeacon call instead of one per event.

interface SwErrorPayload {
  source: string;
  message: string;
  stack?: string;
  swVersion: string;
  timestamp: number;
  replayed?: boolean;
}

const SAMPLE_RATE = 0.25; // ship 1 in 4 occurrences of a repeated fingerprint
const FLUSH_INTERVAL_MS = 5000;

const buffer = new Map<string, { payload: SwErrorPayload; count: number }>();

function fingerprint(payload: SwErrorPayload): string {
  const raw = `${payload.source}:${payload.message.slice(0, 120)}:${payload.swVersion}`;
  let h = 0;
  for (let i = 0; i < raw.length; i++) h = (Math.imul(31, h) + raw.charCodeAt(i)) | 0;
  return Math.abs(h).toString(36);
}

navigator.serviceWorker.addEventListener('message', (event: MessageEvent) => {
  if (event.data?.type !== 'SW_ERROR') return;
  const payload: SwErrorPayload = event.data.payload;
  const key = fingerprint(payload);
  const existing = buffer.get(key);

  if (existing) {
    existing.count += 1; // seen again this window — bump the count, don't re-send
    return;
  }
  // Sample: always ship replayed (post-outage) errors; sample fresh ones
  if (!payload.replayed && Math.random() > SAMPLE_RATE) return;
  buffer.set(key, { payload, count: 1 });
});

function flushBuffer(): void {
  if (buffer.size === 0) return;
  const entries = Array.from(buffer.values()).map(({ payload, count }) => ({
    ...payload,
    occurrences: count,
    fingerprint: fingerprint(payload),
  }));
  buffer.clear();

  const blob = new Blob([JSON.stringify({ errors: entries })], { type: 'application/json' });
  if (!navigator.sendBeacon('/api/telemetry/sw-errors', blob)) {
    void fetch('/api/telemetry/sw-errors', { method: 'POST', body: blob, keepalive: true });
  }
}

setInterval(flushBuffer, FLUSH_INTERVAL_MS);
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flushBuffer(); // don't lose the buffer on tab close
});

Sampling at 25% still catches every fresh fingerprint’s first occurrence — the existing branch only suppresses repeats, so a genuinely new failure mode is never dropped by chance. Replayed payloads (replayed: true, coming from the IndexedDB flush path) bypass sampling entirely, since by definition they already survived long enough to be worth the bandwidth. This buffering pattern mirrors the normalized error payload and Beacon telemetry approach used for page-side async hooks — the shapes differ, but the non-blocking delivery discipline is identical.


Testing and CI/CD validation #

A service worker’s error paths cannot be exercised by unit tests alone, because the behavior under test — event.respondWith resolving despite an internal throw, or a message actually crossing the worker/page boundary — only exists inside a real browser worker thread.

import { test, expect } from '@playwright/test';

test('a forced SW exception is reported to the page via postMessage', async ({ page }) => {
  await page.goto('/');
  await page.waitForFunction(() => navigator.serviceWorker.controller !== null);

  // Arm a listener BEFORE triggering the fault so no message is missed
  const messagePromise = page.evaluate(
    () =>
      new Promise((resolve) => {
        navigator.serviceWorker.addEventListener('message', (event) => {
          if (event.data?.type === 'SW_ERROR') resolve(event.data.payload);
        });
      })
  );

  // Ask the worker to throw deliberately via a dedicated test-only message type
  await page.evaluate(() => {
    navigator.serviceWorker.controller?.postMessage({ type: 'FORCE_TEST_THROW' });
  });

  const payload = await messagePromise;
  expect(payload).toMatchObject({ source: 'sw-error' });
});

test('a navigation still resolves when the fetch handler throws', async ({ page }) => {
  await page.route('**/api/data', (route) => route.abort()); // simulate the failure path
  const response = await page.goto('/dashboard');
  expect(response?.status()).toBeLessThan(500); // guarded handler must degrade, not crash
});

Add a self.addEventListener('message', …) branch in the worker itself that responds to { type: 'FORCE_TEST_THROW' } by throwing synchronously — gate it behind a build-time flag so it never ships to production. In manual debugging, open DevTools → Application → Service Workers to inspect registration state and force an update, then use the Console context selector (top-left dropdown, usually labeled with the worker’s script URL) to run commands inside the worker’s own scope — console.log calls from the page context never appear there, and vice versa.

Page-side telemetry buffering follows the same shape used across Framework-Specific Crash Recovery & Error Handlers, where each framework’s boundary or hook normalizes errors into a structured payload before shipping it — the worker-to-page bridge documented here is simply the PWA-specific front end to that same pipeline. If your app also owns custom hooks for async error catching, route both sources through a single client-side buffer so a dashboard doesn’t need to distinguish “the API call failed” from “the worker that intercepted the API call failed” to get an accurate incident count.


Frequently asked questions #

Why doesn’t window.onerror ever fire for exceptions thrown inside a service worker? A service worker executes in a ServiceWorkerGlobalScope, not a Window. It has no associated document and was never loaded as part of any page’s DOM, so window.onerror — which is scoped to a specific Window object — has no mechanism to observe it. The only listeners with visibility are the ones registered directly on self inside the worker file.

What happens to a page’s navigation if the fetch handler throws without a catch? event.respondWith() requires a promise that resolves to a Response. An uncaught throw means that promise rejects instead, and the browser has no fallback — it renders a network-error state for that navigation. Because one worker controls every request in its scope, an unguarded throw in a commonly hit code path fails every navigation simultaneously, not just the one that happened to trigger it first.

How do you capture service worker errors when no browser tab is open to receive them? Background sync and push events can fire a service worker with zero open clients. clients.matchAll() returns an empty array in that case, so the payload has nowhere to postMessage to. Persist it to an IndexedDB object store instead, then flush the queue during the worker’s next activate event, once a page has reconnected and can accept the message.

Do service worker stack traces map back to readable source locations? Only if the build pipeline emits a source map for the compiled worker bundle as its own artifact and your telemetry backend resolves filename:lineno:colno against the map matching that deploy’s swVersion. Many setups generate maps for the main app bundle only, leaving worker stacks permanently minified.

How do you stop a bad deployment from flooding the telemetry endpoint? Fingerprint each payload from its source, truncated message, and swVersion, then dedupe and sample on the client before calling sendBeacon — sending the first occurrence of a fingerprint per flush window plus an occurrence count, rather than one beacon per thrown exception. A deploy that throws on every request should produce one alert with a count attached, not a denial-of-service against your own telemetry endpoint.