Worker threads, child processes and clustering
Moving CPU-bound work off the event loop, running other programs safely, scaling across cores, and shutting every process type down cleanly.
worker_threads
// main.js
import { Worker } from "node:worker_threads";
const worker = new Worker(new URL("./hash-worker.js", import.meta.url), {
workerData: { file: "big.csv" },
});
worker.on("message", (digest) => console.log("sha256", digest));
worker.on("error", (err) => console.error("worker failed", err));
worker.on("exit", (code) => { if (code !== 0) console.error("exit", code); });
// hash-worker.js
import { parentPort, workerData } from "node:worker_threads";
import { createHash } from "node:crypto";
const hash = createHash("sha256");
// stream workerData.file through hash.update(...) in chunks, then:
parentPort.postMessage(hash.digest("hex"));- A worker has its own heap and its own event loop but shares the process, so it can block without freezing your server.
- Messages are copied with the structured clone algorithm; a
Transferablesuch as anArrayBuffermoves without copying. SharedArrayBufferwithAtomicsis the only way to share mutable memory, and it is worth avoiding unless the data is genuinely large.- Startup costs tens of milliseconds, so spawn a pooled worker per core rather than one per request.
- Work below roughly ten milliseconds of CPU time is usually faster to run inline than to hand to a worker.
child_process
import { spawn, execFile } from "node:child_process";
import { promisify } from "node:util";
import { pipeline } from "node:stream/promises";
// spawn streams output — the safe default for long or large jobs
const child = spawn("ffmpeg", ["-i", "in.mp4", "out.webm"], {
stdio: ["ignore", "pipe", "pipe"],
});
await pipeline(child.stdout, process.stdout);
// execFile runs a known binary with an argument array and captures the output
const { stdout } = await promisify(execFile)("git", ["rev-parse", "HEAD"]);| API | Uses a shell? | Reach for it when |
|---|---|---|
spawn | No | Long-running jobs, streaming output, large results |
execFile | No | A known binary with arguments, output captured in memory |
exec | Yes | Only with hard-coded command strings |
fork | No | Another Node module, with an IPC channel added |
- Argument arrays make shell injection impossible; a shell string built from user input is a remote code execution bug.
- A child inherits the parent environment by default — pass an explicit
envwhen running an untrusted tool. - Set a timeout and kill the process group, or a hung child outlives the request that started it.
- Capture the exit code and the terminating signal separately; a killed process reports a null code and a signal name.
Clustering and graceful shutdown
import cluster from "node:cluster";
import { availableParallelism } from "node:os";
if (cluster.isPrimary) {
for (let i = 0; i < availableParallelism(); i++) cluster.fork();
cluster.on("exit", (worker) => cluster.fork()); // replace a dead sibling
} else {
startServer(); // each worker listens on the same port
}
async function shutdown(signal) {
console.log(signal + " received, draining");
server.close(); // stop accepting, let in-flight requests finish
await pool.end(); // close the database pool
process.exit(0);
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));- Each cluster worker is a full process with its own memory, so clustering costs exactly what running several containers costs.
- In Kubernetes or any orchestrator, scale by adding replicas instead — the platform already restarts failures for you.
- Every shutdown path needs a deadline: if in-flight work has not finished in a few seconds, exit anyway.
- Close resources in dependency order — stop accepting connections, finish requests, close pools, flush telemetry.
- Listen for
SIGTERM, which is what orchestrators send;SIGINTonly covers Ctrl+C on your machine.
💡
Do not pool virtual threads or worker threads for I/O-bound work — the point of both is to create one per task. Worker pools exist for a fixed amount of CPU work, where the cost is the thread and the benefit is parallelism.
FAQ
Worker thread or child process?
A worker thread when the work is CPU-bound and lives in your codebase, since sharing a process is cheaper. A child process when you run a different program, need process-level isolation, or the dependency is not safe to load in-process.
Why does my server use only one CPU core?
Because Node runs your JavaScript on a single thread. Either move the expensive work into workers, or run more processes and let the load balancer spread requests across them.
Related
Events, streams and buffers Async patterns, timers and the event loop
Last refreshed 2026-09-18.