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. NosendBeacon, nodocument, nolocalStorage. Onlyfetch, IndexedDB, Cache Storage and the worker’s own events. - Aggressive termination. The browser kills an idle worker within seconds. A request started outside
event.waitUntilis routinely cancelled mid-flight. - Recursion risk. If the reporting request goes through your own
fetchhandler 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
}
})());
});
Step-by-step #
- Persist synchronously with the failure. IndexedDB is the only durable option in a worker, and the write must be inside a
waitUntilso it completes before termination. - 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. - Bypass your own handler. The early
returnfor the report URL is two lines and prevents the worst failure mode in this whole area. - Flush on wake-ups you already get.
activate, the firstfetchafter a start, andmessagefrom a client are free opportunities — no timers, which would keep the worker alive unnecessarily. - Use
fetchwithkeepalive, notsendBeacon. The latter does not exist in a worker scope. - 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.
Verification #
- 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.
- Throw repeatedly. Fifty failures must produce one row with
count: 50, not fifty rows. - 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.
- 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.
- 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.
Related #
- Service Worker Error Telemetry & Debugging — the wider observability picture for workers
- Debugging a Failing Service Worker with the Application Panel — reading what these reports point at
- Error Telemetry & Crash Reporting — the page-side pipeline these reports join