This page implements the reporting layer of Service Worker Error Telemetry & Debugging, where the runtime constraints are harsher than anything in the page.

The exact problem #

A service worker’s fetch handler throws for one route pattern. Every affected navigation falls back to the network, so the site works — slower, with no offline support, and no error anywhere in the page’s crash dashboard because the failure never happened in a page.

Adding a fetch() call to the worker’s error handler does not fix it. Three constraints get in the way:

  • No window. No sendBeacon, no document, no localStorage. Only fetch, IndexedDB, Cache Storage and the worker’s own events.
  • Aggressive termination. The browser kills an idle worker within seconds. A request started outside event.waitUntil is routinely cancelled mid-flight.
  • Recursion risk. If the reporting request goes through your own fetch handler and that handler is what is broken, each report generates another error, which generates another report.

Zero-to-working buffered reporter #

// sw-telemetry.js — imported by sw.js
const DB_NAME = 'sw_telemetry';
const STORE = 'events';
const MAX_QUEUED = 40;
const REPORT_URL = '/_sw-crash';
let lastFlush = 0;

function openDb() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE, { keyPath: 'fingerprint' });
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

/** Persist first: the worker may be terminated milliseconds from now. */
export async function record(error, context) {
  const fingerprint = fp(error, context);
  const db = await openDb();
  const tx = db.transaction(STORE, 'readwrite');
  const store = tx.objectStore(STORE);

  const existing = await promisify(store.get(fingerprint));
  if (existing) {
    // Repeat: bump the count, do not store another copy.
    store.put({ ...existing, count: existing.count + 1, lastSeen: Date.now() });
  } else {
    const count = await promisify(store.count());
    if (count >= MAX_QUEUED) return;                 // bounded: never fill the origin
    store.put({
      fingerprint,
      count: 1,
      name: error?.name ?? 'Error',
      message: String(error?.message ?? error).slice(0, 300),
      stack: String(error?.stack ?? '').split('\n').slice(0, 12).join('\n'),
      swVersion: SW_VERSION,
      scope: self.registration.scope,
      phase: context.phase,                          // 'install' | 'activate' | 'fetch' | 'message'
      url: context.url ? new URL(context.url).pathname : undefined,
      firstSeen: Date.now(),
      lastSeen: Date.now(),
    });
  }
  await done(tx);
}

/** Flush opportunistically; never more than once a minute. */
export async function flush() {
  if (Date.now() - lastFlush < 60_000) return;
  lastFlush = Date.now();

  const db = await openDb();
  const all = await promisify(db.transaction(STORE, 'readonly').objectStore(STORE).getAll());
  if (all.length === 0) return;

  const res = await fetch(REPORT_URL, {
    method: 'POST',
    keepalive: true,                                 // survives a short idle window
    headers: { 'Content-Type': 'application/json', 'X-SW-Report': '1' },
    body: JSON.stringify({ swVersion: SW_VERSION, events: all }),
  });
  if (!res.ok) return;                               // keep them; try again later

  const tx = db.transaction(STORE, 'readwrite');
  for (const event of all) tx.objectStore(STORE).delete(event.fingerprint);
  await done(tx);
}
// sw.js — wiring
import { record, flush } from './sw-telemetry.js';

self.addEventListener('error', (event) => {
  event.waitUntil?.(record(event.error, { phase: 'global' }));
});
self.addEventListener('unhandledrejection', (event) => {
  event.waitUntil?.(record(event.reason, { phase: 'global' }));
});

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    await cleanupOldCaches();
    await flush();                                    // a good moment: we are awake anyway
  })());
});

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // CRITICAL: never route our own reports through this handler.
  if (url.pathname === REPORT_URL) return;

  event.respondWith((async () => {
    try {
      return await routeRequest(event.request);
    } catch (err) {
      // waitUntil keeps the worker alive while we persist and maybe flush.
      event.waitUntil((async () => { await record(err, { phase: 'fetch', url: event.request.url }); await flush(); })());
      return fetch(event.request);                    // degrade to the network
    }
  })());
});
Persist first, flush on the next wake-up An error box leads to a persist step writing into an IndexedDB queue. A termination marker follows, showing the worker being killed while idle. A later wake-up event leads to a flush step that posts the batched queue to the backend, with a dashed bypass arrow showing the report skipping the worker's own fetch handler. handler throws fetch / install / message record() to IndexedDB dedupe by fingerprint worker terminated seconds later, while idle next wake-up: activate / fetch / message flush() inside event.waitUntil backend the report URL returns early from the fetch handler otherwise a broken handler reports its own reports, forever

Step-by-step #

  1. Persist synchronously with the failure. IndexedDB is the only durable option in a worker, and the write must be inside a waitUntil so it completes before termination.
  2. Deduplicate at write time. Keying the store by fingerprint means a handler failing a thousand times stores one row with count: 1000, not a thousand rows.
  3. Bypass your own handler. The early return for the report URL is two lines and prevents the worst failure mode in this whole area.
  4. Flush on wake-ups you already get. activate, the first fetch after a start, and message from a client are free opportunities — no timers, which would keep the worker alive unnecessarily.
  5. Use fetch with keepalive, not sendBeacon. The latter does not exist in a worker scope.
  6. Attach worker context. Script version, scope, cache names and lifecycle phase replace the route and component stack you would have in a page — the same principle as Building a Structured Error Payload for Crash Reports.
Constraint Consequence Mitigation
No window/sendBeacon Page telemetry libraries do not work as-is fetch + keepalive inside waitUntil
Idle termination In-flight reports are cancelled Persist first, flush on the next event
Own fetch handler intercepts reports Infinite recursion during an incident Early return for the report path
No user or session context Reports are hard to correlate Include scope, SW version and cache names
Storage shared with app data Telemetry can evict recovery snapshots Hard cap at a few dozen rows
Multiple clients open Duplicate reports One worker, one queue — dedupe by fingerprint

Correlating worker and page reports #

A page can ask the worker what it knows, which is what makes an incident timeline possible.

// sw.js
self.addEventListener('message', (event) => {
  if (event.data?.type !== 'telemetry:sync') return;
  event.waitUntil((async () => {
    await flush();
    const client = event.source;
    client?.postMessage({ type: 'telemetry:state', swVersion: SW_VERSION, queued: await queueSize() });
  })());
});
// page: register-sw.ts
navigator.serviceWorker.ready.then((reg) => {
  reg.active?.postMessage({ type: 'telemetry:sync' });
});
navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data?.type !== 'telemetry:state') return;
  // Stamp page crash reports with the worker version that served them.
  setReportContext({ swVersion: event.data.swVersion, swQueued: event.data.queued });
});

Stamping page reports with the worker version is what lets you answer “did this only break for users on the old worker?” — the question that resolves most PWA incidents, and one that neither side can answer alone. The lifecycle mechanics behind those version mismatches are covered in Service Worker Lifecycle & Registration Recovery.

Joining page and worker reports on the worker version Two source boxes, page reports and service worker reports, both flow into a backend box. A shared field labelled swVersion links them, and a note explains that this is what answers whether a failure is limited to one worker build. page crash reports scope, route, component stack worker reports phase, cache names, scope joined on swVersion one incident, both sides backend "only users still on worker 4.18 are affected" is a five-second query once both sides carry the version Thousands of failures, one bounded queue Three bars for one incident: raw occurrences in the thousands, stored rows capped at forty, and flush requests limited to one per minute. A note explains that this is what stops a broken worker from overwhelming its own reporting endpoint. raw occurrences 4 210 rows stored 40 (capped) flush requests 3 (one per minute) counts are preserved on the stored rows, so impact is still accurate

Verification #

  1. Throw in the fetch handler. Confirm one row appears in the telemetry store, that navigation still works via the network fallback, and that a flush arrives at the backend within a minute.
  2. Throw repeatedly. Fifty failures must produce one row with count: 50, not fifty rows.
  3. Kill the worker mid-flush. Stop the worker from DevTools during a flush; the queue must survive and re-send on the next wake-up.
  4. Check the recursion guard. Temporarily remove the report-path early return and confirm the request count explodes — then put it back. Knowing the failure mode first-hand keeps the guard in place through future refactors.
  5. Verify correlation. Force a page-level crash and a worker-level error in the same session; both reports must carry the same swVersion.

Frequently asked questions #

Why do my service worker errors never reach the backend?

Because the worker was terminated before the request completed. Persist first, send later, and wrap the send in event.waitUntil so the browser keeps the worker alive.

Can I use sendBeacon inside a service worker?

No — it is not available in ServiceWorkerGlobalScope. Use fetch with keepalive inside event.waitUntil.

How do I stop a broken worker from spamming my endpoint?

Fingerprint and count instead of copying, cap the queue at a few dozen entries, and refuse to flush more than once per interval.