10LOC
Node.jsadvanced

worker_threads for CPU-bound work off the main thread

Published September 4, 2026

import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";

const fibonacci = (n: number): number => (n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2));

if (isMainThread) {
  const worker = new Worker(new URL(import.meta.url), { workerData: 40 });
  worker.once("message", (result: number) => console.log(`fib(40) = ${result}`));
  worker.once("error", (err) => console.error("worker failed:", err));
} else {
  parentPort!.postMessage(fibonacci(workerData as number));
}

What

This file plays both roles: run as the main thread, it spins up a Worker pointed at itself; run as that worker, it computes fibonacci(40) (deliberately naive recursion — real CPU work) and reports the result back over postMessage.

Why it matters

Node's event loop is single-threaded. fibonacci(40) computed recursively is on the order of hundreds of millions of function calls — run it inline in an HTTP handler and every other request on that process stalls until it's done, because there's no I/O for the event loop to interleave around synchronous CPU work. async/await doesn't help here either; it only yields at genuine I/O boundaries, and there isn't one.

worker_threads gives you an actual second thread with its own V8 instance and event loop, so the heavy computation runs in parallel with the main thread instead of blocking it. That's different from child_process: workers share memory via ArrayBuffer transfer and have lower spin-up overhead, making them the better fit when the unit of work is "one function, one machine, needs more CPU" rather than "isolate this in its own OS process."

How it works

  • isMainThread is true in the process that started normally, false inside any thread spawned via new Worker(...) — that single flag is what lets one file define both sides.
  • new Worker(new URL(import.meta.url), { workerData: 40 }) re-runs this same module in a new thread, passing 40 in as workerData. workerData is structured-cloned across the thread boundary, so it's a real copy, not a shared reference.
  • Inside the worker, isMainThread is false, so execution falls through to parentPort!.postMessage(fibonacci(workerData as number))parentPort is only non-null inside a worker, hence the assertion.
  • Back on the main thread, worker.once("message", ...) receives that return value once the computation finishes, without ever blocking the main thread's own event loop while it waits.

Gotchas

  • workerData and messages are structured-cloned (or transferred, for ArrayBuffer/MessagePort), never shared by reference — mutating an object in the worker does not mutate the original in the main thread.
  • Always attach an 'error' listener on the Worker; an uncaught exception inside the worker doesn't crash the main thread, but it also won't surface anywhere unless you listen for it.
  • Spinning up a Worker has real overhead (a new V8 isolate, ~a few milliseconds). For work that's frequent but individually cheap, keep a pool of long-lived workers instead of creating one per task.

Related

  • child_process.fork() — the process-level equivalent; heavier isolation and IPC overhead, but no shared-memory footguns and full OS-level separation.
  • AsyncLocalStorage — solves a different problem (propagating context across async calls on one thread), not to be confused with actual parallelism.