Debugging unhandledrejection in Service Workers
A promise that rejects inside a Service Worker never touches your page — there is no window, no DevTools tab that stays open, and the worker is often terminated seconds after the rejection fires. This how-to shows exactly where to attach the unhandledrejection listener in the worker’s global scope and how to ship the reason out before the runtime kills the thread. It is a companion to Handling Unhandled Promise Rejections in Modern JS and sits inside Core JavaScript Error Handling & Boundaries.
Symptom / Trigger
The rejection is real, but the evidence is thin. In the page’s console you see nothing at all — the worker has its own execution context. If you open chrome://inspect → Service Workers and inspect the worker while it is still alive, you may catch a fragment like this, then watch it vanish when the worker stops:
Uncaught (in promise) TypeError: Failed to fetch
at sw.js:42:19
at async handleFetch (sw.js:40:22)
// worker inspector, moments later:
Service worker "https://app.example.com/sw.js" stopped.
Because the worker lifecycle is event-driven, the fetch or push handler that owned the rejecting promise has already returned. Your normal window.onunhandledrejection and any page-level Sentry or Bugsnag snippet are loaded on the document — none of them run inside sw.js. The result is a rejection that is genuinely uncaught, produces no user-visible error, and leaves no durable trace once the worker is garbage-collected.
The problem is made worse by intermittency. A worker often serves dozens of fetch events per navigation, and only one code path — a stale cache miss, an offline POST, a failed background sync — actually rejects. By the time you reproduce it, the worker that hit the bug is long gone, and the next worker starts clean. Without a listener that ships the reason off-device, you are left guessing which handler and which request produced the failure your users quietly experienced.
Root Cause Explanation
A Service Worker runs in a ServiceWorkerGlobalScope, not a Window. The global object is self, and window is undefined. Any error-reporting code that reaches for window, document, or a DOM element silently fails to install — often throwing during the worker’s initial evaluation, which itself becomes a second swallowed error.
The broken pattern is usually a reporting shim copied straight from page code:
// BROKEN inside sw.js — window does not exist in a Service Worker
window.addEventListener('unhandledrejection', (event) => {
reportError(event.reason); // ReferenceError: window is not defined
});
Even when developers correctly target self, a second problem appears: the worker can be terminated the instant its current event settles. A fetch() call to your logging endpoint started from inside the rejection handler is not tracked by the browser as part of any lifecycle event, so the runtime feels free to tear the worker down mid-request. The report is initiated and then abandoned in flight. Capturing the rejection and delivering it are two separate problems, and both must be solved for the reason to actually arrive.
Step-by-Step Fix
1. Attach the listener to self, the Service Worker global scope
Inside sw.js the correct global is self. It exposes the same unhandledrejection event the page uses, but scoped to the worker. Register it at the top level of the worker script so it is present before any fetch or push handler ever runs.
// sw.js — self is the ServiceWorkerGlobalScope; window is undefined here
self.addEventListener('unhandledrejection', (event) => {
console.error('SW rejection:', event.reason); // fires inside the worker
});
2. Normalize event.reason into a plain, serializable object
A rejection reason is not always an Error. Code can reject with a string, a Response, or an arbitrary object, and Error instances do not survive JSON.stringify because message and stack are non-enumerable. Flatten the reason into an explicit shape you control so nothing important is silently dropped in transit.
function toReport(reason) {
// reason can be an Error, a string, or any thrown value
if (reason instanceof Error) {
return { name: reason.name, message: reason.message, stack: reason.stack };
}
return { name: 'NonError', message: String(reason), stack: null };
}
3. Keep the worker alive with event.waitUntil() while the report is sent
The unhandledrejection event exposes waitUntil(), which tells the browser the worker still has meaningful work in flight and must not be terminated until the passed promise settles. Wrap the whole reporting call in it so an async POST is not cut short the moment the handler returns.
self.addEventListener('unhandledrejection', (event) => {
const body = JSON.stringify(toReport(event.reason));
// waitUntil holds the worker open until the POST settles
event.waitUntil(reportRejection(body));
});
4. Deliver with fetch(keepalive) so the request survives worker teardown
keepalive: true moves the request onto the browser’s keepalive budget, which is designed to outlive the context that started it. Combined with waitUntil(), it closes the last gap: even a worker shut down under memory pressure leaves the report in flight to your endpoint.
async function reportRejection(body) {
// keepalive lets the request outlive the worker if it is terminated early
await fetch('/telemetry/sw-rejection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
});
}
5. Register the worker from the page and confirm it activated
The only part of this flow that runs on the page is registration. Log the resolved scope so you can confirm the worker actually installed — a listener inside sw.js is worthless if the script never activated in the first place.
// app.js on the page — this is the only part that touches window/navigator
navigator.serviceWorker.register('/sw.js').then((reg) => {
console.log('SW registered, scope:', reg.scope); // installs the worker
});
Steps 1 through 4 all live inside sw.js. The listener runs in the worker, the reason is flattened so it survives JSON.stringify, waitUntil() extends the worker’s lifetime past the current event, and keepalive gives the network request its own budget so it completes even if the browser decides to shut the thread down the moment the handler returns.
Verification
Prove the path end to end by throwing a deliberate rejection inside the worker and asserting the endpoint received it. This test uses @cloudflare/vitest-pool-workers style globals, but the assertion works in any harness that gives you a spy on fetch.
// sw.test.js — verify the rejection is captured and reported
import { vi, test, expect } from 'vitest';
import './sw.js'; // registers the self listener
test('unhandledrejection is reported via keepalive fetch', async () => {
const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('ok'));
// simulate a rejection the runtime would surface to the global scope
const event = new PromiseRejectionEvent('unhandledrejection', {
promise: Promise.reject(new TypeError('Failed to fetch')),
reason: new TypeError('Failed to fetch'),
});
self.dispatchEvent(event);
await new Promise((r) => setTimeout(r, 0)); // flush the microtask queue
expect(spy).toHaveBeenCalledWith('/telemetry/sw-rejection', expect.objectContaining({
method: 'POST',
keepalive: true, // proves the delivery used a survivable request
}));
});
A passing run confirms the listener fired in the worker scope and the report was dispatched with keepalive. In production, the corresponding proof is a row landing on /telemetry/sw-rejection with a stack field that points into sw.js — that is the trace the DevTools console never let you keep.
Edge Cases & Gotchas
-
event.preventDefault()only silences the console warning; it does not stop your handler. Call it if you have fully handled the rejection and want to suppress the browser’s default “Uncaught (in promise)” logging. Omit it while debugging so you still see the native message in the worker inspector. -
waitUntil()has a runtime budget — do not lean on it for retries. The browser grants a bounded extension (roughly 30 seconds in Chromium, shorter under memory pressure). A slow logging endpoint plus a retry loop can exceed it and get killed anyway. Keep the report a single fast POST; queue durable retries in IndexedDB and drain them from a later event instead. -
A rejection during the worker’s initial evaluation is not an
unhandledrejection. Ifsw.jsthrows while the browser is first parsing and running it, the worker fails to install and no listener exists yet to catch anything. Guard top-levelawaitand module-load side effects with their owntry/catch, and watch theupdatefound/statechangeevents on the registration from the page. -
self.onerrorcatches synchronous throws, not promise rejections. The two are separate global hooks in a worker. Register both —self.addEventListener('error', ...)for uncaught exceptions andself.addEventListener('unhandledrejection', ...)for rejected promises — or a whole class of failures reports through only one channel. See Best Practices for try/catch in Async Loops for containing rejections before they ever reach the global scope.
FAQ
Why does my page-level Sentry (or window.onunhandledrejection) never fire for worker rejections?
Because they are installed on the document’s window, and a Service Worker has no window — it runs in a separate ServiceWorkerGlobalScope. The two contexts do not share global handlers. You must attach a dedicated self.addEventListener('unhandledrejection', ...) inside sw.js and route its reports to the same backend your page uses, tagged so you can tell worker events apart from page events.
Do I need both waitUntil() and fetch(keepalive), or is one enough?
Use both, because they cover different gaps. event.waitUntil() extends the worker’s lifetime so your async handler runs to completion instead of being cut off when the event settles. keepalive protects the individual network request so it can still complete even if the worker is torn down. Together they make delivery reliable; either alone leaves a window where the report can be dropped.
How do I stop the browser from logging “Uncaught (in promise)” once I am handling it?
Call event.preventDefault() inside your unhandledrejection listener after you have queued the report. That suppresses the runtime’s default console output without affecting your own logging. Leave it out during active debugging so the native message still appears in the worker inspector. For structuring the payload you send, see How to Log Custom Error Properties Without Blooming Payloads.
Related
- Handling Unhandled Promise Rejections in Modern JS — the parent guide on intercepting rejections across every JavaScript runtime
- Best Practices for try/catch in Async Loops — containing rejections locally before they escape to the worker’s global scope
- How to Log Custom Error Properties Without Blooming Payloads — shaping the serialized reason you POST from the worker
- Core JavaScript Error Handling & Boundaries — full reference for error boundaries across page, worker, and process scopes