Handling Uncaught Exceptions in Worker Threads

When code inside a Node.js worker_threads Worker throws and nothing catches it, the error does not land in your main thread’s try/catch or your process-level handler the way most developers expect. Instead it surfaces as an 'error' event on the Worker instance, and if you never subscribed to that event the whole process crashes with a stack trace that points into the worker, not into the caller who spawned it. This how-to shows the exact wiring to carry a worker’s uncaught exception back across the thread boundary with its message, stack, and a meaningful exit code intact — a specialised corner of Node.js uncaughtException vs unhandledRejection inside the broader core JavaScript error handling boundaries reference.

Symptom / Trigger

You spawn a worker to do CPU-bound parsing, the worker throws somewhere deep inside, and your main thread either never finds out or the entire Node process dies before your caller can react. A typical crash looks like this:

node:internal/worker:275
      throw new ERR_WORKER_UNSUPPORTED_OPERATION(...);

Error: Cannot read properties of undefined (reading 'width')
    at resizeFrame (/app/workers/resize.js:31:22)
    at MessagePort.<anonymous> (/app/workers/resize.js:12:5)
    at [nodejs.internal.kHybridDispatch] (node:internal/event_target:826:20)
    at exit (node:internal/process/execution:130:5)

[main] Worker exited with code 1 — parent promise never settled

The give-away is the last line: the worker exited with code 1, but the Promise you were awaiting on the main thread never resolved or rejected. Your request handler hangs forever, or your process exits with a stack that names a file the caller has never heard of.

This failure is especially confusing because it behaves differently depending on how you spawned the worker. If you fire a worker and forget about it, the throw takes down the whole process. If you carefully wrapped the worker in a Promise but only listened for its success message, the process survives but the awaiting caller stalls indefinitely — no timeout, no rejection, just a request that never completes and eventually trips an upstream gateway timeout. Both variants trace back to the same missing listener, and both are invisible in local testing where the worker rarely throws.

Root Cause Explanation

An uncaught throw inside a Worker does not propagate up a call stack into the parent — the two threads have entirely separate call stacks and event loops. Node catches the worker-side exception at the top of the worker’s own stack and re-emits it as an 'error' event on the corresponding Worker object in the parent. If no listener is attached, Node treats it as an unhandled 'error' event and terminates the process. The broken pattern is spawning a worker and only listening for its result message:

// Broken: only 'message' is handled — an uncaught throw is invisible here
const worker = new Worker('./resize.js', { workerData: job });
worker.on('message', (result) => resolve(result));
// no 'error' and no 'exit' listener → thrown error crashes the process

Because the parent only subscribed to 'message', a worker-side throw has nowhere to go. The Promise wrapping this worker never settles (the resolve is never called), and the raw 'error' event bubbles up to become an uncaught exception in the main thread. The stack you see belongs to the worker, which is why it looks foreign.

Worker error crossing the thread boundaryA vertical flow showing a throw inside the worker becoming an error event on the parent Worker object, then crashing the process when unhandled.Throw inside workerRe-emitted as error eventNo listener: process crashes

There is a second subtlety. When a Worker emits 'error', Node has already serialized the error for you — the Error object handed to your listener is a structured clone of the original, so err.message and err.stack survive, but custom prototype methods and non-enumerable properties do not. That distinction matters when you rethrow, because the object you receive looks like an Error but is not the same instance the worker threw and no longer descends from your custom error class.

It helps to think of the worker boundary as a network hop rather than a function call. Everything that crosses it — arguments in, results out, and errors as a side channel — passes through the structured clone algorithm, the same mechanism postMessage uses. Functions, class identity, and getters do not survive that trip. Once you internalise that the parent only ever sees a flattened copy of the worker’s error, the correct fix stops being “catch the throw” and becomes “reconstruct a faithful error on the receiving side.” The remaining steps build exactly that reconstruction.

Step-by-Step Fix

  1. Attach an 'error' listener to every Worker so the uncaught throw becomes a value you can inspect.
// spawn.js — never create a Worker without an 'error' listener
import { Worker } from 'node:worker_threads';

const worker = new Worker('./resize.js', { workerData: job });
worker.on('error', (err) => {
  // err is a structured clone: message and stack are preserved
  console.error('[worker] uncaught:', err.stack);
});
  1. Wrap the whole worker lifecycle in a Promise and settle it from the 'error', 'message', and 'exit' events together.
// runWorker.js — one Promise that always settles exactly once
function runWorker(file, workerData) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(file, { workerData });
    worker.once('message', resolve);        // success path
    worker.once('error', reject);           // uncaught throw in worker
    worker.once('exit', (code) => {         // safety net for silent exits
      if (code !== 0) reject(new Error(`Worker stopped with exit code ${code}`));
    });
  });
}
  1. Rehydrate the cloned error on the main thread so its type and stack read naturally to the caller.
// rethrow.js — restore a real Error the caller can branch on
worker.once('error', (cloned) => {
  const err = new Error(cloned.message);   // fresh Error, correct prototype
  err.stack = cloned.stack;                // keep the worker-side stack
  err.cause = cloned;                      // retain the raw clone for logging
  err.workerFile = file;                   // add context the worker lacked
  reject(err);
});
  1. Report the failure inside the worker before it dies, so serialization loss never hides the true cause.
// resize.js (worker side) — catch, post a rich message, then rethrow
import { parentPort, workerData } from 'node:worker_threads';
try {
  parentPort.postMessage(resizeFrame(workerData));
} catch (err) {
  // Send a plain object the parent can read even if the clone drops fields
  parentPort.postMessage({ __error: { message: err.message, stack: err.stack } });
  throw err; // still throw so the 'error' event and non-zero exit both fire
}

Before and after wiring worker error propagationLeft column lists the broken single-listener setup; right column lists the three-event Promise that always settles.BEFOREOnly message listenerThrow crashes processPromise never settlesAFTERerror, exit, messageStack rehydratedRejects the caller

Registering 'error', 'exit', and 'message' as once listeners guarantees the wrapping Promise settles exactly one time no matter which event fires first. The 'exit' listener is the true safety net: if the worker calls process.exit(1) directly, or is killed for running out of memory, no 'error' event fires at all, and only the non-zero exit code tells you something went wrong.

The ordering of these events is worth internalising. On a normal throw, 'error' fires first with the cloned exception, then 'exit' fires with a non-zero code a moment later. Because both listeners call the same Promise settlement functions and those functions are no-ops after the first call, the 'exit' handler that runs second harmlessly does nothing — the caller already has the rich error from 'error'. On a process.exit() or hard crash the order collapses to 'exit' only, and that lone event carries the code the caller rejects with. Wiring all three is therefore not redundant defensive coding; each event covers a failure mode the others cannot see, and settling once keeps them from fighting.

Step 4 closes the last gap. Serialization loss means a custom ValidationError with a .field property arrives at the parent as a bare Error missing .field. By posting a plain object over parentPort before rethrowing, you hand the parent a lossless copy it can merge into the rehydrated error, while the subsequent throw still guarantees the 'error' event and non-zero exit both fire for the safety net.

Verification

Spawn a worker that deliberately throws, then confirm the parent’s Promise rejects with a rehydrated error rather than crashing the process:

// verify.js — run with: node verify.js
import { Worker, isMainThread } from 'node:worker_threads';

if (isMainThread) {
  const worker = new Worker(new URL(import.meta.url), { workerData: null });
  const settled = new Promise((resolve, reject) => {
    worker.once('error', reject);
    worker.once('exit', (code) =>
      code === 0 ? resolve('clean') : reject(new Error(`exit ${code}`)));
  });
  settled
    .then(() => console.log('unexpected success'))
    .catch((err) => console.log('caught in main:', err.message)); // <-- expected
} else {
  throw new Error('boom from worker');
}

Expected output — the throw is caught on the main thread and the process exits 0:

caught in main: boom from worker

If you instead see a raw Error: boom from worker stack ending in node:internal/worker and a non-zero exit, an 'error' listener is missing.

Edge Cases & Gotchas

  • process.exit() inside the worker fires no 'error' event. A worker that calls process.exit(1) or aborts on out-of-memory skips 'error' entirely. Only the 'exit' event with a non-zero code reveals the failure, which is why Step 2 wires all three events rather than relying on 'error' alone.

  • The error you receive is a structured clone. Custom error subclasses lose their prototype across the boundary, so err instanceof MyError is false on the main thread. Preserve a discriminator field (for example err.code) inside the worker and branch on that instead of instanceof.

  • Unhandled rejections inside the worker escalate too. Since Node 15 an unhandled Promise rejection in a worker becomes an uncaught exception, so it also surfaces as the parent’s 'error' event — the same wiring covers both, as detailed in debugging race conditions in async error handlers.

  • A late listener still misses the first throw. If the worker throws synchronously during module evaluation before you attach 'error', the event can fire before your listener registers. Attach all listeners in the same synchronous tick that constructs the Worker, never after an await.

Recovery pipeline for a worker exceptionThree stages: the error event is caught, a real Error is rebuilt with its stack, and the wrapping promise rejects to the caller.Catch error eventRebuild with stackReject the caller

FAQ

Why doesn’t a try/catch around new Worker(...) catch the worker’s error?

Because the throw happens on a different thread with its own call stack. new Worker() only starts the thread and returns immediately — by the time the worker executes and throws, the constructor has long returned and the surrounding try/catch is no longer on the stack. Node bridges the gap by re-emitting the exception as an 'error' event on the Worker object, so you must listen for that event rather than wrap the constructor.

Do I still need a process-level uncaughtException handler if I wire every worker’s 'error' event?

Yes, as a backstop. Per-worker 'error' listeners handle errors that Node successfully catches at the top of the worker’s stack, but a hard crash such as an out-of-memory abort can skip that path. A main-thread uncaughtException handler plus the 'exit' code check gives you full coverage. See the parent guide on Node.js uncaughtException vs unhandledRejection for how the two layers relate.

Is the original error object the same one the worker threw?

No. Node passes your listener a structured clone, so err.message and err.stack are copied but the object identity, prototype chain, and any non-enumerable fields are lost. Rebuild a fresh Error on the main thread and copy over the fields you care about, as shown in Step 3, so downstream instanceof checks and logging behave predictably.