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 Transferable such as an ArrayBuffer moves without copying.
  • SharedArrayBuffer with Atomics is 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"]);
APIUses a shell?Reach for it when
spawnNoLong-running jobs, streaming output, large results
execFileNoA known binary with arguments, output captured in memory
execYesOnly with hard-coded command strings
forkNoAnother 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 env when 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; SIGINT only 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.

Events, streams and buffers Async patterns, timers and the event loop

Last refreshed 2026-09-18.