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.

Page scope versus Service Worker scopeBefore column shows the page context with window, document, and a persistent console. After column shows the Service Worker context with self only, no window, and a runtime that may terminate the thread after each event settles.PAGE SCOPEwindow + document existconsole stays openpage snippet catches itWORKER SCOPEonly self existsthread may terminatemust extend the event

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.

Rejection capture and delivery pipelineThree stages: a rejected promise inside a fetch handler, the unhandledrejection listener attached to self, and a keepalive POST that delivers the report to the telemetry endpoint.promise rejectsself listenerwaitUntil holdskeepalive POST

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. If sw.js throws while the browser is first parsing and running it, the worker fails to install and no listener exists yet to catch anything. Guard top-level await and module-load side effects with their own try/catch, and watch the updatefound / statechange events on the registration from the page.

  • self.onerror catches synchronous throws, not promise rejections. The two are separate global hooks in a worker. Register both — self.addEventListener('error', ...) for uncaught exceptions and self.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.

From capture to durable deliveryFour stacked stages: capture the rejection on self, serialize the reason into a plain object, hold the worker open with waitUntil, and deliver the report with a keepalive request.capture on selfserialize the reasonwaitUntil holds workerkeepalive delivery

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.