Fixing Uncaught Exceptions in Dedicated Workers
An uncaught exception in a Dedicated Worker takes down the task that threw it — and if the failure came from a promise, the main thread is never told at all. This page is part of Error Handling & Crash Recovery within the Debugging, Profiling & Production Optimization reference, and covers the narrow case of a single Dedicated Worker: how to reproduce both failure modes, catch both, report both, and restart cleanly.
Before you write any handler code, confirm which of the two modes you are actually looking at:
- A synchronous throw in worker code fires an
ErrorEventon theWorkerobject on the main thread. You will see it in the console asUncaught <ErrorType>attributed to the worker script. - An unhandled rejection fires nothing on the
Workerobject. The console line readsUncaught (in promise)and it is attributed to the worker context — that message with no correspondingerrorevent on the main thread is the signature of a missingunhandledrejectionlistener inside the worker. - A load failure (bad path, 404, MIME type refused, syntax error in the script) also arrives as an
ErrorEventon theWorkerobject, but no code inside the worker ever ran, so no in-worker listener can help you.
Minimal Reproducible Example
Two files reproduce the whole problem. The worker throws on demand in each of the two ways; the main thread listens on every channel the Worker interface offers.
// crash-worker.ts — reproduces both failure modes on demand
export type CrashCommand = { kind: 'sync' } | { kind: 'async' };
self.addEventListener('message', (event: MessageEvent<CrashCommand>) => {
if (event.data.kind === 'sync') {
// Throws straight out of the event handler — the event loop sees it.
throw new RangeError('sync failure inside the message handler');
}
// Fire-and-forget promise. Nothing awaits it, nothing catches it,
// and it settles on a later microtask — after this handler has returned.
void Promise.reject(new TypeError('async failure with no catch'));
});
// main.ts — observe what actually reaches the main thread
import type { CrashCommand } from './crash-worker';
const worker = new Worker(new URL('./crash-worker.ts', import.meta.url), { type: 'module' });
worker.addEventListener('error', (event: ErrorEvent) => {
console.log('error event →', event.message, event.filename, event.lineno);
});
worker.addEventListener('messageerror', (event: MessageEvent) => {
console.log('messageerror →', event); // fires only on deserialization failure
});
worker.postMessage({ kind: 'sync' } satisfies CrashCommand); // → logs an error event
worker.postMessage({ kind: 'async' } satisfies CrashCommand); // → logs nothing at all
Run it and the asymmetry is immediate: the first postMessage produces one error event → line, the second produces silence on the main thread and a bare Uncaught (in promise) TypeError in the console. A supervisor built only on worker.onerror therefore has a permanent blind spot exactly where most real worker code fails — inside fetch, IndexedDB, WebAssembly.instantiate and every other promise-returning API available in worker scope.
ErrorEvent on the Worker object; the rejection settles a microtask later, when there is no longer any call stack to unwind, and nothing crosses the thread boundary at all.The Capture Boundary: Two Listeners, First Two Statements
A single try/catch cannot span a thread boundary and cannot see a rejection that settles after the block exits. The fix is a two-listener boundary registered at the very top of the worker module, before any import side effect, any module-level computation, or any importScripts call has a chance to throw.
// worker.js — these must be the first two statements in the file
self.onerror = (message, source, lineno, colno, error) => {
self.postMessage({
type: 'FATAL_SYNC',
payload: sanitizeError(error ?? new Error(`${message} at ${source}:${lineno}:${colno}`))
});
return true; // marks the error handled: keeps the scope alive for a state flush
};
self.addEventListener('unhandledrejection', (event) => {
self.postMessage({
type: 'FATAL_ASYNC',
payload: sanitizeError(event.reason)
});
event.preventDefault(); // suppress the default console report; we own the channel now
});
// ...everything else in the worker comes after this point
If any synchronous code at the top level of the worker script throws before your listeners are registered, neither self.onerror nor unhandledrejection will fire. Move all computation into message handlers or lazy-init functions, and place the listener registrations as the first two statements in the file. In a bundled module worker the hazard is subtler: a static import is evaluated before your own module body, so a throwing dependency escapes the boundary entirely — use a dynamic import() inside the message handler for anything that can fail at load time.
import to a dynamic import() inside a handler is what drags it from the unguarded region into the guarded one.Step-by-Step Walkthrough
self.onerror receives five positional arguments, not an event. Unlike the addEventListener('error') form, the classic handler is called with (message, source, lineno, colno, error). The fifth argument is the actual Error instance and is the only one carrying a stack; the first four are the fallback for engines or contexts that withhold it. The error ?? new Error(...) expression means you always hand sanitizeError a real Error object and never have to branch downstream.
return true marks the error as handled. Returning a truthy value from self.onerror is the worker-scope equivalent of event.preventDefault(): it suppresses the default reporting and stops the ErrorEvent from propagating to the Worker object on the main thread. That last part matters — if you both return true and rely on worker.onerror, your main-thread handler goes quiet. Pick one reporting channel and keep it. The pattern here posts an explicit FATAL_SYNC message so the main thread receives a structured payload rather than the flattened ErrorEvent, which loses the stack cross-origin.
event.reason can be anything. unhandledrejection gives you whatever was passed to Promise.reject, and that is not required to be an Error. Promise.reject('timeout'), Promise.reject(response) and Promise.reject(undefined) are all legal and all appear in real dependency code. sanitizeError therefore has to handle a non-Error value without throwing inside the error handler — an exception raised inside onerror is not re-entrant and simply disappears.
event.preventDefault() keeps the thread out of the console’s hands. Chrome’s default action for an unhandled rejection in a worker is to report it to the console and, in some embedders, to abort the worker. Calling preventDefault() suppresses that default so your postMessage reliably reaches the main thread before any teardown begins.
Both handlers post, neither recovers. Nothing above attempts to resume the failed task. The stack has already unwound and any partially applied mutation is still partially applied; the handler’s only job is to get diagnostics across the boundary while the scope is still alive.
Bounding the Error Payload
Error objects are structured-cloneable in Chrome 98+, Firefox 93+ and Safari 16.4+, but only name, message and stack survive — prototype identity, custom subclass properties and non-enumerable fields are dropped, and Firefox historically stripped stack as well. Extract the diagnostic primitives by hand instead, and cap the stack so a framework trace cannot dominate the message queue.
// worker.js
function sanitizeError(err) {
if (!(err instanceof Error)) {
// Promise.reject('timeout') and friends
return { name: 'NonErrorThrow', message: String(err), valueType: typeof err, stack: '' };
}
return {
name: err.name,
message: err.message,
stack: err.stack?.split('\n').slice(0, 5).join('\n') ?? '',
timestamp: performance.now()
};
}
Five frames is enough to identify the throw site and its two or three callers, which is what you need to act. If you are shipping these payloads to a backend rather than to a console, use the fuller typed pair described in Structured Error Serialization Across Threads, which preserves cause chains and DOMException.code, and feed the result into worker stack-trace capture in Sentry so the frames are source-mapped.
| Strategy | Latency impact | Payload size (typical) | Recommendation |
|---|---|---|---|
postMessage(err) directly |
Low | Engine-dependent, stack may be dropped |
Only for quick local debugging |
JSON.stringify(err) |
High — walks own enumerable props, yields {} for a plain Error |
Unbounded if the error carries a response body | Avoid |
| Manual extraction, 5 frames | ~0.02 ms | 0.4–1 KB | Production standard |
| Manual extraction, full stack | ~0.03 ms | 4–40 KB with framework frames | Only when debugging deep async chains |
| Debounced batch (500 ms window) | Adds up to 500 ms reporting lag | One message per burst | Use when a loop can throw per iteration |
Isolating the Throw Site in DevTools
Console logging fails at exactly the wrong moment: a worker that crashes mid-task may never flush its buffered output. Attach the debugger instead. In the Sources panel, use the Threads pane to select the worker’s execution context — the panel’s scope, call stack and breakpoints then apply to the worker isolate rather than to the page. Enable Pause on uncaught exceptions and add your framework and bundler runtime to Ignore list so the pause lands on your own frame instead of three levels down a scheduler. The wider workflow, including source-map setup for bundled workers, is covered in Chrome DevTools Worker Debugging.
A liveness check before you attach saves time, because a worker that failed to load looks identical to a worker that is merely idle:
// main.ts — confirm the worker booted before attaching the debugger
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
worker.addEventListener('message', (event: MessageEvent<{ type: string }>) => {
if (event.data.type === 'PONG') console.log('Worker is alive; safe to attach.');
});
worker.postMessage({ type: 'PING' });
If no PONG arrives and no error event fires either, the script URL resolved to something that never executed — usually a bundler output-path mismatch, which is the failure mode discussed in bundling module workers with Vite and webpack.
Respawning with Jittered Exponential Backoff
Once a FATAL_SYNC or FATAL_ASYNC message arrives, the compromised worker is diagnostic evidence, not a work unit. Detach its listeners, terminate it, and spawn a replacement on a delay that doubles per attempt with full jitter, so a page that lost several workers to the same bad input does not respawn them in lockstep.
// main.js
const BASE_DELAY_MS = 250;
const MAX_DELAY_MS = 10_000;
const MAX_RESTARTS = 5;
const STABILITY_WINDOW_MS = 30_000;
let worker = null;
let attempts = 0;
let stabilityTimer = null;
function spawnWorker() {
if (attempts >= MAX_RESTARTS) {
onUnrecoverable('Worker restart limit reached; falling back to main-thread compute.');
return;
}
// Full jitter: a uniform draw from [0, capped delay] rather than the delay itself.
const capped = Math.min(BASE_DELAY_MS * 2 ** attempts, MAX_DELAY_MS);
const delay = attempts === 0 ? 0 : Math.random() * capped;
attempts++;
setTimeout(() => {
worker = new Worker('./worker.js', { type: 'module' });
worker.onmessage = handleMessage;
// Load failures never reach the in-worker listeners, so this net is still required.
worker.onerror = (event) => {
console.error('Worker failed to load:', event.message, event.filename);
recycle();
};
// Reset the counter only after the replacement has survived a stability window,
// otherwise a worker that crashes every 40 s restarts forever at zero delay.
stabilityTimer = setTimeout(() => { attempts = 0; }, STABILITY_WINDOW_MS);
}, delay);
}
function recycle() {
clearTimeout(stabilityTimer);
if (worker) {
worker.onmessage = null;
worker.onerror = null;
worker.terminate(); // synchronous and unconditional: no drain, no final message
worker = null;
}
spawnWorker();
}
function handleMessage({ data }) {
if (data.type === 'FATAL_SYNC' || data.type === 'FATAL_ASYNC') {
console.warn(`Worker crashed (${data.type}):`, data.payload.name, data.payload.message);
console.warn(data.payload.stack);
recycle();
return;
}
// ...normal result handling
}
spawnWorker();
Any in-flight tasks the terminated worker owned are lost — terminate() does not drain the queue. If the worker was holding UI-critical work, re-queue those task IDs against the replacement; the queue-ownership pattern is set out in handling worker termination gracefully in SPAs.
Gotchas & Edge Cases
Script error. with lineno: 0 means the worker script is cross-origin. When a worker is loaded from a different origin, the browser redacts event.message, event.filename and event.lineno on the ErrorEvent delivered to the main thread. Serve the worker script from the same origin, or send Access-Control-Allow-Origin and construct the Worker with a request that is CORS-enabled. Note that the in-worker self.onerror handler still sees the real error — this redaction is another reason to report through your own postMessage channel rather than through worker.onerror.
Errors during module evaluation bypass your in-worker listeners entirely. A syntax error, a failed import resolution, or a throw inside a statically imported dependency all happen before your first statement executes. These surface as an ErrorEvent on the Worker object with no in-worker involvement, which is why worker.onerror on the main thread stays wired up in the supervisor above even though the worker reports its own runtime faults.
A DataCloneError inside the error handler loses the error. If you post the raw event.reason and it happens to be a non-cloneable value — a Response, a function-bearing object, a Proxy — postMessage itself throws, inside the handler, where nothing catches it. The failure then looks like the worker silently ignoring its own crash. Sanitizing to a plain object first removes the class of problem; the cloneability rules are enumerated in the structured clone algorithm walkthrough.
A late .catch() fires rejectionhandled, not a retraction. If a rejected promise acquires a handler after the unhandledrejection event has fired, the worker dispatches a rejectionhandled event — but your FATAL_ASYNC message has already been sent and the supervisor may have already terminated the worker. In code that attaches handlers asynchronously (deferred retry wrappers are the usual culprit), listen for rejectionhandled and post a retraction, or classify these rejections as warnings rather than fatals.
Error storms can outpace the message queue. A for loop that throws on every one of 50,000 rows will post 50,000 messages, each of which allocates and clones. Guard with a counter that posts the first error verbatim and then a single summary — { suppressed: 49_999 } — when the burst ends.
Performance Note
The capture boundary itself is free at steady state: an installed onerror handler and an unhandledrejection listener that never fire cost nothing beyond two property writes at startup. The cost is entirely on the failure path, and it is small — sanitizeError on a five-frame stack runs in roughly 0.02 ms in V8, and the resulting 0.4–1 KB payload crosses the boundary in the 0.05–0.2 ms typical of a small postMessage round trip.
Restart is the expensive part. Spawning a Dedicated Worker costs a few milliseconds for the agent plus the full parse-and-evaluate time of the script and its imports — commonly 5–50 ms for an application worker with a WebAssembly or parser dependency, and more if the module graph is not warm in the HTTP cache. That asymmetry is the rule of thumb worth keeping: reporting an error is cheap enough to do on every failure, restarting is expensive enough that it needs a backoff and a retry cap. Budget at most one error message per animation frame and let anything beyond that be summarized.