A single slow computation in the main thread freezes every request your server is handling.
Node runs your JavaScript on one thread. That's perfect for I/O, but a CPU-bound task — hashing, image resizing, parsing a huge JSON file, a tight numeric loop — blocks the event loop: no other requests are served, no timers fire, until it finishes.
`node:worker_threads` runs JavaScript on additional threads. Each Worker has its own V8 isolate, event loop and module graph; workers don't share variables with the main thread. They communicate by messages:
- Create one with
new Worker(new URL("./worker.js", import.meta.url), { workerData }). - In the worker,
workerDataholds the initial input, andparentPort.postMessage(result)sends results back. - The parent listens for
"message","error"(an uncaught exception in the worker) and"exit".
Messages are copied with the structured clone algorithm — plain objects, arrays, Maps, Dates, typed arrays work; functions and class instances' prototypes don't survive. Large binary data can be transferred instead of copied by listing an ArrayBuffer in the transfer list.
Starting a worker costs a few milliseconds and some megabytes, so don't spawn one per request. For anything frequent, keep a pool of long-lived workers sized to the CPU count (os.availableParallelism()); libraries like piscina do this well.
Workers are the wrong tool for I/O: the main thread already does I/O concurrently. Use them only when profiling shows JavaScript computation is the bottleneck. Native operations such as crypto.pbkdf2, zlib and fs already run on libuv's thread pool when you use their async versions — prefer those over hand-rolled workers.
Example
// fib-worker.js — one file acting as both main thread and worker
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); } // deliberately slow
if (isMainThread) {
const runFib = (n) => new Promise((resolve, reject) => {
const worker = new Worker(new URL(import.meta.url), { workerData: n });
worker.once("message", resolve);
worker.once("error", reject);
});
const ticker = setInterval(() => console.log("main thread still responsive"), 200);
console.log("fib(40) =", await runFib(40)); // computed on another thread
clearInterval(ticker);
} else {
parentPort.postMessage(fib(workerData));
}Remember
- CPU-bound JS blocks the event loop; workers run it on other threads
- Workers share nothing by default and talk via structured-clone messages
- Reuse workers through a pool; never spawn one per request
Watch out for
- Using worker threads for I/O-bound work, adding overhead with no benefit.