Use worker_threads when the slow part is JavaScript you wrote and it needs the CPU: parsing a large file, resizing an image in WASM. Use child_process when the slow part is another program, or when a crash must not take your server with it. Use cluster when the goal is an HTTP server on every core, not one task on another core.
The three modules are not interchangeable, because they differ in what they share with the parent: a thread shares the process, a child shares nothing but a pipe, and a cluster worker shares a listening port.
Why does one slow function stall every request?
Node runs your JavaScript on one thread. The event loop picks up a callback, runs it to completion, then picks up the next. While a request handler spends 400ms computing a PDF, nothing else moves: no other request is parsed, no timer fires, no socket is read. The Node.js guide on the event loop says it plainly: the loop “should orchestrate client requests, not fulfill them itself”, and for anything beyond trivial work the advice is to move it off the loop onto a worker pool (Don’t Block the Event Loop).
The I/O you already use is fine. fs, DNS and crypto hand their work to libuv’s own thread pool and the loop carries on. That pool has no JavaScript API for your own functions, though; the guide’s only route into it is a C++ addon. For your own CPU work you need one of the three modules below.
When is worker_threads the right pick?
When the work is JavaScript (or WASM called from JavaScript) and you want it in the same process. The worker_threads docs are explicit about the scope: “Workers (threads) are useful for performing CPU-intensive JavaScript operations. They do not help much with I/O-intensive work.” Do not reach for a worker to parallelise database calls. It will cost you a thread and gain you nothing.
A worker is a second V8 isolate and a second event loop inside the one process. It starts faster than a process, it can share a SharedArrayBuffer with the parent because they share an address space, and it talks to the parent over a MessagePort using the structured clone algorithm, so you can post a Map, a Date or a nested object without serialising it to JSON yourself.
The bare API:
// main.js
import { Worker } from 'node:worker_threads';
export function totalsInWorker(csv) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./totals-worker.js', import.meta.url), {
workerData: { csv },
resourceLimits: { maxOldGenerationSizeMb: 256 },
});
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => {
if (code !== 0) reject(new Error(`worker exited with code ${code}`));
});
});
}
// totals-worker.js
import { parentPort, workerData } from 'node:worker_threads';
const totals = new Map();
for (const line of workerData.csv.split('\n')) {
const [sku, qty] = line.split(',');
if (sku) totals.set(sku, (totals.get(sku) ?? 0) + Number(qty));
}
// A Map survives structured clone; no JSON.stringify needed.
parentPort.postMessage(totals);
Do not ship that as-is in a server. Spawning one worker per request is the thread version of the fork bomb the Node guide warns about: you can accept requests faster than you can start isolates. Use a pool. Piscina is the one I reach for, because its defaults match the docs’ advice without configuration: minThreads and maxThreads derived from os.availableParallelism(), a maxQueue you can cap, resourceLimits passed straight through to each worker, and an AbortSignal on every task.
import { Piscina } from 'piscina';
const pool = new Piscina({
filename: new URL('./totals-worker.js', import.meta.url).href,
maxQueue: 'auto',
});
// totals-worker.js now exports a function instead of reading workerData:
// export default ({ csv }) => { ...same loop...; return totals; };
const totals = await pool.run({ csv }, { signal: AbortSignal.timeout(5000) });
If you size a pool yourself, use os.availableParallelism(), not os.cpus().length. The os docs say the latter “should not be used to calculate the amount of parallelism available to an application”.
A worker is a separate isolate, so an AsyncLocalStorage store set in the parent is not visible inside it. If your request context lives in ALS, as in our post on AsyncLocalStorage request context in Node, put the request ID in the message.
When does child_process win?
Two cases.
The first is when the work is not JavaScript. Transcoding with ffmpeg, rendering with Chromium, converting documents with LibreOffice. A worker thread cannot help; you need a process, and you want execFile, not exec. The child_process docs carry a bold warning on exec: “Never pass unsanitized user input to this function. Any input containing shell metacharacters may be used to trigger arbitrary command execution.” execFile skips the shell, so arguments are arguments and never commands.
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export async function thumbnail(input, output) {
// No shell: a filename containing ; or $( ) is just a filename.
await execFileAsync('ffmpeg', ['-i', input, '-vframes', '1', '-vf', 'scale=320:-1', output], {
timeout: 30_000,
});
}
Watch maxBuffer on the sync and callback variants. It defaults to 1024 * 1024 bytes, and when stdout exceeds it the child is killed and the output truncated. If a tool is chatty, use spawn and stream.
The second case is isolation. A worker thread shares the process with your server. If the work calls a native addon that segfaults, or allocates until V8 aborts, the whole process dies, workers and HTTP listener together. resourceLimits catches the memory case for pure JavaScript: the docs say reaching a limit “leads to termination of the Worker”, the parent sees an ERR_WORKER_OUT_OF_MEMORY, and the server survives. It does nothing for a native crash. child_process.fork() gives you a separate Node process with an IPC channel, and the only thing that dies is the child.
The price of that isolation is the channel. fork serialises messages as JSON by default. Pass serialization: 'advanced' and the channel switches to the structured clone format, which the docs say supports “BigInt, Map and Set, ArrayBuffer and TypedArray, Buffer, Error, RegExp”, but it is still a copy through a pipe. There is no SharedArrayBuffer between processes.
Also mind the two shutdown events. 'exit' fires when the process ends; 'close' fires only after its stdio streams have closed as well. If you read the output on 'exit', you can miss the tail of it.
Where does cluster fit?
cluster is not a work queue. It forks copies of your whole application and shares the listening port between them. The default on every platform except Windows is round-robin: the primary accepts each connection and hands it to the next worker process. If the aim is “use all eight cores for HTTP”, that is the tool, and the cluster docs draw the line themselves: “When process isolation is not needed, use the worker_threads module instead.”
If you deploy as containers, the orchestrator already does this job: N replicas behind a load balancer spread across cores without cluster in the code. Where it still earns its place is a single VM, one process tree, one log stream, and a port shared without a reverse proxy in front.
How do you pass data without copying it?
Structured clone copies. Post a 200 MB ArrayBuffer to a worker and you briefly have two of them. The fix is the second argument to postMessage, the transfer list:
worker.postMessage({ pixels: buf }, [buf]);
// buf.byteLength is now 0 in the parent; ownership moved to the worker
The docs list what can be transferred: ArrayBuffer, MessagePort, FileHandle, and the net.Server and net.Socket family. Everything else is cloned. A SharedArrayBuffer is the third option: it is neither copied nor transferred, both threads read and write the same bytes, and you coordinate with Atomics. If an object must never leave the parent, markAsUntransferable(object) makes any attempt to transfer it throw.
None of this applies to child_process. Across a process boundary every message is a copy, which is one more reason to keep CPU-bound JavaScript in a thread.
What I would actually do
Default to a Piscina pool over worker_threads for CPU-bound JavaScript, sized by os.availableParallelism(), with resourceLimits set so one bad input cannot eat the heap. Reach for execFile the moment the work is an external binary, and for fork only when a native dependency has crashed on you before or you need a hard memory wall that resourceLimits cannot give. Leave cluster to the case where one box must serve on every core without a proxy in front, and otherwise scale with more containers.
I would not use any of them for I/O. If the handler is slow because it waits on Postgres or a third-party API, a worker just waits in a different thread. Fix the query, add a cache, or set a deadline on the request, as in our post on cancelling a fetch with a timeout in Node.
Whoooop builds and tunes Node.js services for UK clients, and moving CPU work off the event loop is a regular part of that, from image pipelines to PDF generation. If your p99 climbs whenever one route gets busy, our Node.js development team can find the blocking call and move it.