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.
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.
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.
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.