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
isMainThreadistruein the process that started normally,falseinside any thread spawned vianew 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, passing40in asworkerData.workerDatais structured-cloned across the thread boundary, so it's a real copy, not a shared reference.- Inside the worker,
isMainThreadisfalse, so execution falls through toparentPort!.postMessage(fibonacci(workerData as number))—parentPortis 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
workerDataand messages are structured-cloned (or transferred, forArrayBuffer/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 theWorker; 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
Workerhas 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.