Handling unhandledrejection Inside Workers

A worker’s error event covers synchronous throws. Everything asynchronous — a failed fetch, a rejected WebAssembly instantiation, an await in a handler nobody catches — fails through a different channel, and a worker that listens only for error never hears it.

The result is a distinctive production symptom: the feature stops working, the worker is alive, and no error is reported anywhere. This guide completes the coverage in Error Handling & Crash Recovery, part of Debugging, Profiling & Production Optimization.


Both Listeners, at the Top of the Worker

// worker.ts — register before anything else can fail
function report(kind: 'error' | 'unhandledrejection', detail: Record<string, unknown>): void {
  self.postMessage({ kind: 'WORKER_FAILURE', channel: kind, at: Date.now(), ...detail });
}

self.addEventListener('error', (event: ErrorEvent) => {
  report('error', {
    message: event.message,
    file: event.filename,
    line: event.lineno,
    column: event.colno,
    stack: event.error?.stack,          // present when the throw carried an Error
  });
});

self.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
  const reason = event.reason as unknown;
  report('unhandledrejection', {
    message: reason instanceof Error ? reason.message : String(reason),
    name: reason instanceof Error ? reason.name : typeof reason,
    stack: reason instanceof Error ? reason.stack : undefined,
  });
  // We have reported it ourselves; suppress the duplicate console entry.
  event.preventDefault();
});

// Rarely needed, but it prevents false alarms in slow-attachment cases.
self.addEventListener('rejectionhandled', (event: PromiseRejectionEvent) => {
  report('unhandledrejection', { message: 'previously reported rejection was later handled' });
});

Three listeners, three distinct conditions. error for synchronous throws, unhandledrejection for promises that reach a microtask checkpoint with no handler, and rejectionhandled for the case where a handler is attached after the rejection was already reported — which happens when a promise is stored and awaited later, and which otherwise leaves a false failure in your telemetry.

Which failures travel through which event Two columns of failure sources mapped to the event that reports them. Through the error event: a synchronous throw in a message handler, a syntax error during module evaluation, and a throw inside a setTimeout callback. Through the unhandledrejection event: a rejected fetch inside an async handler, a WebAssembly instantiation failure, an async function whose result nobody awaits, a rejected promise stored for later, and any await that throws in a handler without a try-catch. A note records that a worker registering only the error listener is deaf to the entire right-hand column, which is where most real failures occur. Two channels, and only one is usually wired 'error' — synchronous throws · a throw in a message handler · a syntax error while evaluating · a throw inside a timer callback · importScripts failing to load the channel almost everyone wires 'unhandledrejection' — async failures · a rejected fetch in an async handler · WebAssembly instantiation failure · an async call nobody awaited · a promise stored and awaited later · any await that throws without a catch the channel where most real failures arrive A worker with only the left-hand listener reports nothing when a network call fails — the feature simply stops answering, and the console stays empty.
Modern worker code is almost entirely asynchronous, which is why the right-hand column carries the majority of production failures.

The Silent Failure, Concretely

// The bug: an async handler with no catch, and no rejection listener.
self.addEventListener('message', async (event: MessageEvent) => {
  const config = await fetch('/api/config').then((r) => r.json());   // throws when offline
  const result = compute(event.data, config);
  self.postMessage({ id: event.data.id, result });
});

When the fetch rejects, the async function’s returned promise rejects. Nobody holds it — addEventListener ignores return values — so the rejection is unhandled. The page’s await on that request never settles; there is no error, no timeout, and no console output unless DevTools happens to be open.

The fix is a try/catch in the handler and the global listener as a backstop:

self.addEventListener('message', async (event: MessageEvent) => {
  const { id } = event.data;
  try {
    const config = await fetch('/api/config').then((r) => r.json());
    self.postMessage({ id, ok: true, result: compute(event.data, config) });
  } catch (error) {
    // Always answer the request — a caller waiting forever is worse than a failure.
    self.postMessage({ id, ok: false, error: serialise(error) });
  }
});

The rule worth generalising: every request must produce a reply, including failures. A promise on the page that never settles is the most expensive failure mode available, because it presents as a hang rather than an error and leaves a pool slot occupied. The global listener then exists for failures outside any request — a timer, a stream callback, a background refresh.


Forwarding With a Usable Cause

event.reason is whatever was rejected, and it is frequently not an Error: a string, a Response, a DOMException, or undefined from a bare Promise.reject(). Normalise before reporting, or the telemetry fills with "[object Object]":

function serialise(reason: unknown): { name: string; message: string; stack?: string } {
  if (reason instanceof Error) {
    return { name: reason.name, message: reason.message, stack: reason.stack };
  }
  if (reason instanceof Response) {
    return { name: 'HttpError', message: `${reason.status} ${reason.url}` };
  }
  return { name: typeof reason, message: String(reason) };
}

Structured clone strips prototypes, so a custom error subclass arrives on the page as a plain object regardless — the reconstruction techniques are in Structured Error Serialization Across Threads. What matters here is that the worker extracts name, message and stack while it still has the real object, because none of them survive by themselves.

Include context the page cannot know: which request was in flight, how long the worker had been running, how many jobs it had completed. A rejection reported with the input that triggered it is diagnosable; one reported alone usually is not.


Phantom Rejections and How to Avoid Them

Three patterns generate rejections that are reported and then turn out to be fine, and each is worth recognising because they train teams to ignore the channel.

A promise stored and awaited later. const p = doWork(); … await p; reports an unhandled rejection at the microtask checkpoint between the two lines if doWork fails fast. rejectionhandled fires when the await finally attaches, which is why that third listener is worth having. Better: attach a no-op catch at creation (p.catch(() => {})) and handle the failure at the await.

Promise.all with several failures. Only the first rejection settles the aggregate; the others are unhandled. Promise.allSettled avoids this entirely and is almost always what was meant.

Fire-and-forget calls. void refreshCache(); is idiomatic and produces an unhandled rejection whenever it fails. If the failure genuinely does not matter, say so explicitly — refreshCache().catch(reportQuietly) — so a real regression is not mistaken for the usual noise.


Deciding What the Page Should Do

A forwarded failure needs a policy, and treating every one the same is what makes error handling feel useless. Three classes, three responses.

Failures tied to a request. The reply already carries ok: false, so the caller’s promise rejects and the component renders its error state. Nothing global is needed, and the global listener should not double-report these — tag request failures so the backstop can ignore them.

Failures with no owner. A background refresh, a stream callback, a timer. Nobody is waiting, so the correct response is to record it and, if it recurs, degrade the feature deliberately — disable the background refresh rather than letting it fail every thirty seconds forever.

Failures that indicate the worker is unusable. A WebAssembly instantiation failure, a missing dependency, a repeated allocation failure. Here the worker cannot serve any request, so the supervisor should stop sending it work and surface a clear message rather than accumulating rejections one request at a time.

// page.ts — classify once, at the boundary
worker.addEventListener('message', (event: MessageEvent) => {
  if (event.data.kind !== 'WORKER_FAILURE') return;
  const { name, message } = event.data;

  if (FATAL_NAMES.has(name)) return supervisor.markUnusable(new Error(message));
  telemetry.record('worker_failure', { name, message });
});

The distinction between the second and third classes is what stops a page from limping: a feature that has failed twenty times in a row is not experiencing intermittent errors, and treating it as though it were produces both a bad experience and a flood of near-identical reports.


Four asynchronous failures and where each one surfaces Four concrete failures. A fetch that rejects inside an async message handler leaves the page promise pending forever unless the handler catches it. A WebAssembly instantiation that fails during startup rejects with no caller. A background refresh nobody awaited produces an unhandled rejection with no owner. And a stream callback that throws on every chunk produces thousands of identical rejections in seconds. Where each asynchronous failure ends up a fetch rejects inside an async message handler the page promise never settles — a hang, not an error WebAssembly instantiation fails at startup rejects with no caller; only the global listener sees it a background refresh nobody awaited an unhandled rejection with no owner and no context a stream callback throws on every chunk thousands of identical reports in seconds — rate-limit it Only the first has a caller to reject. The rest need the global listener to exist at all.
The first row is the most expensive failure a worker can produce, because it presents as a hang.

Gotchas

Registering the listener after an await. A listener installed at the end of an async bootstrap misses every failure during startup, which is when the interesting ones happen. Register synchronously at the top of the module.

Calling preventDefault() without reporting. Suppressing the console entry and forgetting to forward the event produces a worker that is silent by design. Only suppress when your own reporting has taken over.

Assuming the worker is dead. An unhandled rejection does not stop the worker; it keeps running and keeps accepting messages. Deciding whether to restart is a policy question, and for most workers the answer is no — reply with a failure and continue.

Forwarding rejections without deduplication. A stream callback failing on every chunk generates thousands of identical reports. Rate-limit by message and stack before posting, or a single bug consumes an error budget in seconds.

Missing messageerror. A third event, separate from both: it fires when an incoming message cannot be deserialised. Rare, but it is silent otherwise and usually indicates a caller sending something non-cloneable.


Reporting the same failure from two layers. A try/catch that reports and rethrows, plus a global listener that reports again, doubles every entry and makes rates meaningless. Decide which layer owns reporting — usually the boundary — and let the other only annotate.

Testing only the success path. The listeners above are code, and code that never runs in a test is code that does not work. Add one test per worker that rejects deliberately and asserts the page received a WORKER_FAILURE with a non-empty message; the runner setup is in Testing Workers in CI.


Report volume from one bug, before and after rate limiting Two measurements of the same streaming bug over ninety seconds. Without rate limiting the worker produced about fourteen thousand identical reports, at roughly 0.1 to 0.3 milliseconds each, so the reporting itself became the bottleneck. With deduplication by message and stack plus a per-minute cap, the same bug produced about sixty reports carrying the same information. One bug, ninety seconds of a streaming worker no rate limiting ≈14,000 reports — the bottleneck deduplicated and capped ≈60 reports, same diagnostic value 0 5k 10k 14k reports The information content of the two runs is identical; only the cost differs.
Rate limiting belongs in the worker handler — by the time reports reach a backend, the CPU has already been spent.

Performance Note

The listeners cost nothing when nothing fails — event registration is a constant, and no work happens on the success path. The measurable cost is in the failure path: serialising a stack and posting it is roughly 0.1–0.3 ms per report, which is irrelevant for occasional failures and significant at thousands per second. In one production case a per-chunk rejection in a streaming worker generated 14,000 reports in 90 seconds, and the reporting itself became the bottleneck — which is why rate limiting belongs in the handler, not in the backend.

The broader lesson is that a worker needs both channels wired before it does anything else, because the failures that matter most in production are the ones that arrive when nobody is watching a console. Two listeners at the top of the module make every subsequent failure visible.

Frequently Asked Questions

Why does self.onerror not fire for a rejected promise?
error is dispatched for uncaught exceptions — a synchronous throw that unwinds to the top of a task. A rejected promise with no handler is not an exception; it is a value in a rejected state, and the engine reports it separately through unhandledrejection once a microtask checkpoint passes with no handler attached. Two events, two listeners; a worker that registers only the first is deaf to most asynchronous failures.
Can I recover from an unhandled rejection, or only report it?
Report it, and decide policy. event.preventDefault() suppresses the default console reporting, which is appropriate when you are forwarding the failure to your own telemetry and do not want it duplicated. What you cannot do is resume the operation — by the time the event fires, the promise chain that failed has no continuation left to run, so recovery means retrying the operation, not repairing it.

See also