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 ErrorEvent on the Worker object on the main thread. You will see it in the console as Uncaught <ErrorType> attributed to the worker script.
  • An unhandled rejection fires nothing on the Worker object. The console line reads Uncaught (in promise) and it is attributed to the worker context — that message with no corresponding error event on the main thread is the signature of a missing unhandledrejection listener inside the worker.
  • A load failure (bad path, 404, MIME type refused, syntax error in the script) also arrives as an ErrorEvent on the Worker object, 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.

The two failure modes travel different distances Three lifelines: worker task code, the worker event loop, and the main thread. In lane A a synchronous throw leaves the message handler, unwinds through the event loop and is dispatched as an ErrorEvent on the Worker object, where worker.onerror fires. In lane B a fire-and-forget Promise.reject is queued as a microtask, settles after the handler has already returned, and the arrow towards the main thread is struck through — no event is ever dispatched on the Worker object, so worker.onerror stays silent. Worker — task code Worker — event loop Main thread A · synchronous throw the Worker object hears it throw new RangeError handler exits abruptly ErrorEvent on the Worker object message · filename · lineno worker.onerror fires B · unhandled rejection the main thread hears nothing void Promise.reject(…) no await, no .catch() settles on a microtask the handler already returned worker.onerror silent the rejection never crosses
Both failures start in the same message handler. The synchronous throw unwinds through the event loop and is redispatched as an 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
Register listeners before any other worker code

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.

Where the capture boundary sits in the worker's evaluation order The worker script read top to bottom. Above the boundary sit the static imports and any top-level computation; a throw there never reaches self.onerror and surfaces instead as an ErrorEvent on the Worker object. The two listener registrations — self.onerror and the unhandledrejection listener — form the boundary itself. Below it, dynamic imports and message handlers are trapped, sanitized and posted to the main thread as FATAL_SYNC or FATAL_ASYNC. worker.js — evaluation order, top to bottom import { parse } from './heavy-dep.js'; evaluated before your module body ever runs const table = buildLookupTable(); top-level work — can throw before your line 1 Outside the boundary a throw here never reaches self.onerror — it surfaces as a load error on the Worker self.onerror = (msg, src, line, col, err) => { … } self.addEventListener('unhandledrejection', h); The capture boundary two listeners, registered as the first two statements capture boundary installed const { parse } = await import('./heavy-dep.js'); a failing dependency is now a catchable rejection self.addEventListener('message', runTask); throws and rejections both land on a listener Inside the boundary both failure modes are caught, sanitized and posted back as FATAL_SYNC / FATAL_ASYNC
The boundary is positional, not lexical: it protects only what is evaluated after it. Moving a risky dependency from a static 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
Serialize cost and payload size, strategy by strategy A logarithmic payload-size axis from 0.1 to 100 kilobytes with a one-kilobyte budget line. Posting the Error instance directly is engine-dependent and drawn as an open range. JSON.stringify is unbounded, running off the right edge, because the payload carries whatever the error references. Manual extraction of five stack frames costs about 0.02 milliseconds and lands at 0.4 to 1 kilobyte, inside the budget. Manual extraction of the full stack costs about 0.03 milliseconds and lands at 4 to 40 kilobytes. A debounced batch keeps the small payload but adds up to 500 milliseconds of reporting lag. strategy serialize cost payload size per error 0.1 KB 1 KB 10 KB 100 KB postMessage(err) directly low engine-dependent · stack may be dropped JSON.stringify(err) high unbounded · grows with what the error holds manual extraction, 5 frames ~0.02 ms 0.4–1 KB · production standard manual extraction, full stack ~0.03 ms 4–40 KB debounced batch, 500 ms +500 ms lag same payload, one message per burst 1 KB per-error budget
Only the manual five-frame extraction sits inside the budget while still carrying a usable stack. The two open-ended bars are the real hazard: neither has an upper bound you control, so a single error that references a response body can outweigh every legitimate message in the queue.

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();
Full-jitter backoff across five restart attempts A chart of restart delay against attempt number. The shaded band for each attempt spans the whole interval from zero to the capped delay, which doubles from 500 milliseconds on attempt two to 4 seconds on attempt five; attempt one spawns immediately. A dot inside each band marks one sampled delay: 0, 180 milliseconds, 640 milliseconds, 1.42 seconds and 2.31 seconds. A note explains that the 10-second cap never binds because five attempts top out at 4 seconds. Below, a 30-second stability window runs from the final spawn to the point where the attempt counter resets to zero. full-jitter envelope: uniform on [0, capped] one sampled delay 4 s 3 s 2 s 1 s 0 0 ms · immediate ≤ 500 ms ≤ 1 s ≤ 2 s ≤ 4 s 180 ms 640 ms 1.42 s 2.31 s attempt 1 attempt 2 attempt 3 attempt 4 attempt 5 The 10 s cap never binds here. MAX_RESTARTS = 5, so the largest capped delay is 250 × 2⁴ = 4 s, well short of the cap. Stability window a crash before it elapses keeps the counter, so the next delay doubles again spawn 30 s of uninterrupted work before the counter is trusted attempts = 0
Full jitter draws uniformly from the whole shaded interval rather than waiting the capped delay itself, so several pages that lost a worker to the same bad input spread their retries instead of respawning in lockstep. The counter only returns to zero once a replacement has survived the 30-second window.

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

Frequently Asked Questions

Why doesn't try/catch inside a worker catch async rejections?
A try/catch only covers synchronous throws within its block. A promise that rejects outside an await chain — a fire-and-forget fetch(), a .then() with no .catch(), or a rejected promise returned from an event handler — settles later, on a microtask, long after the try block has exited. Register self.addEventListener('unhandledrejection', handler) as the first statement of the worker script to intercept these, and call event.preventDefault() so the rejection is reported by your own channel instead of only the console.
Does returning true from self.onerror keep the worker usable?
It keeps the global scope alive — the worker is not torn down and later messages still arrive — but it does not rescue the task that threw. That call stack has already unwound, so any half-mutated state it left behind is still half-mutated. Treat return true as a window in which to flush diagnostics and post a final checkpoint, then let the main thread call terminate() and respawn rather than trusting the surviving scope with new work.

See also