Wise Hustlers — Digital Product & App Development Studio Logo
Get Consultation
By Wise Hustler Admin9/6/20268 min read

Worker Threads vs Child Processes vs Clustering in Node.js: When to Use Which

Worker Threads vs Child Processes vs Clustering in Node.js: When to Use Which

# Worker Threads vs Child Processes vs Clustering in Node.js: When to Use Which

TL;DR: Use worker_threads for CPU-bound work you want to run without blocking the event loop, use child_process when you need to run separate programs or want hard OS-level isolation between tasks, and use the cluster module (or, more commonly today, container replicas) to scale a single Node.js server across multiple CPU cores. They solve three different problems, and picking the wrong one is usually why "I added concurrency and nothing got faster" happens.

Node.js runs your JavaScript on a single thread. That's fine for I/O-bound work — the event loop happily juggles thousands of concurrent database queries or HTTP requests because it's not doing the waiting itself, libuv is. It falls apart the moment you need to burn CPU: parsing a huge JSON payload, resizing images, running a crypto hash, computing a diff, or anything else that keeps the thread busy for more than a few milliseconds. That's the actual problem all three of these APIs exist to solve, just at different layers.

The core distinction: threads vs. processes

Before the comparison table, the one fact that explains almost every decision here:

  • `worker_threads` creates additional V8 isolates inside the same OS process. Threads can share memory directly via SharedArrayBuffer or hand off ownership of data via transferable ArrayBuffers, which avoids the serialization cost you pay everywhere else.
  • `child_process` and `cluster` both spawn separate OS processes. Each has its own V8 instance, its own memory space, and its own event loop. Nothing is shared; everything crossing the boundary is copied and serialized over IPC (or stdio pipes).

That single distinction drives the tradeoffs: threads are cheaper to spin up and can share data, but a crash inside a worker thread can be harder to isolate cleanly from the rest of the process; separate processes cost more memory and startup time but a crashed child can't take the parent down with it.

Worker Threads: for CPU-bound work inside one process

worker_threads (stable since Node 12, still the recommended API in current Node 24/26) is the right tool when the bottleneck is computation, not I/O, and you want to keep it inside a single service instance.

// worker.js
const { parentPort, workerData } = require('node:worker_threads');

function fib(n) {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

parentPort.postMessage(fib(workerData.n));
// main.js
const { Worker } = require('node:worker_threads');

function runFib(n) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./worker.js', { workerData: { n } });
    worker.once('message', resolve);
    worker.once('error', reject);
    worker.once('exit', (code) => {
      if (code !== 0) reject(new Error(`Worker stopped with exit code ${code}`));
    });
  });
}

runFib(42).then((result) => console.log(result));

In production you almost never spawn a bare Worker per task — thread creation isn't free, and an unbounded pool will thrash the OS scheduler. Use a pool. Piscina (currently at v5.x on npm) is the de facto standard worker-pool library for this: it manages a configurable min/max pool of threads, queues tasks with backpressure, and supports cancellation and memory limits out of the box.

const Piscina = require('piscina');
const pool = new Piscina({ filename: require.resolve('./worker.js') });

const result = await pool.run({ n: 42 });

Worker threads are the right default for CPU-bound work in a containerized deployment: you're not paying for a second process's memory overhead, and you keep one clean unit (one container, one process) per replica.

Child Processes: for isolation and running other programs

child_process (spawn, exec, execFile, fork) creates an entirely separate OS process. Reach for it when:

  • You need to run something that isn't Node.js — ffmpeg, imagemagick, a Python script, a shell command.
  • You want a crash to be someone else's problem — a bad child_process can be killed and restarted without ever threatening the parent's memory or event loop.
  • You need to run untrusted or unpredictable code where isolation matters more than speed.
const { spawn } = require('node:child_process');

const convert = spawn('ffmpeg', ['-i', 'input.mp4', '-vf', 'scale=1280:-1', 'output.mp4']);

convert.stdout.on('data', (d) => console.log(d.toString()));
convert.stderr.on('data', (d) => console.error(d.toString()));
convert.on('close', (code) => {
  console.log(`ffmpeg exited with code ${code}`);
});

child_process.fork() is a special case worth calling out: it spawns a new Node.js process and wires up an IPC channel automatically, so you get process.send()/.on('message') messaging between parent and child without manually managing stdio. It's a reasonable middle ground when you want process-level isolation for a Node.js task but don't need the HTTP-server-specific load balancing that cluster provides.

Cluster: for scaling a server across cores

The cluster module is a purpose-built wrapper around child_process.fork() for one specific job: taking a single Node.js server and running one copy per CPU core, all listening on the same port.

const cluster = require('node:cluster');
const http = require('node:http');
const os = require('node:os');

if (cluster.isPrimary) {
  const numCPUs = os.availableParallelism();
  for (let i = 0; i < numCPUs; i++) cluster.fork();

  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} died, restarting`);
    cluster.fork();
  });
} else {
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('handled by worker ' + process.pid);
  }).listen(3000);
}

Per the Node.js cluster documentation, the primary process listens on the port and distributes incoming connections to workers round-robin (SCHED_RR) on every platform except Windows, where the OS handles distribution instead. Each worker is a full, isolated Node.js process — if one crashes, the others keep serving, and the exit handler above lets you restart it.

A 2026 caveat worth stating plainly: if you're already deploying to Kubernetes, ECS, or any orchestrator that runs multiple container replicas, your replica count is your cluster. Running the cluster module inside a pod that the orchestrator is already replicating adds a layer of process management that duplicates what the orchestrator does, without much upside beyond slightly faster recovery from a single worker crash. cluster still earns its place on a single VM or a bare-metal box where nothing else is handling multi-core scheduling for you — it's just no longer the default answer once you're in containers.

Comparison table by use case

Use caseWorker ThreadsChild ProcessCluster
CPU-bound work (image processing, hashing, parsing)Best fit — shares memory, low overhead, keep it in-processWorks, but pay full process overhead per taskNot designed for this — it's a server-scaling tool, not a task-offload tool
Process isolation (crash containment, untrusted code, non-Node programs)Weak — a fatal error can still affect the isolate's stability model; not a true security boundaryBest fit — separate memory space, separate crash domain, can run any executableProvides isolation as a side effect, but it's scoped to identical server workers, not arbitrary tasks
Horizontal scaling across cores for a serverPossible but awkward — you'd have to build your own request routingPossible via manual fork() + IPC, but you're reinventing clusterBest fit on VMs/bare metal; redundant if an orchestrator already replicates containers
Shared in-memory state across workersYesSharedArrayBuffer / transferable objectsNo — IPC only, everything serializedNo — IPC only, everything serialized
Startup/memory costLow (shared V8 heap infra, cheaper threads)High (full process per child)High (full process per worker)

Decision guide

  • The event loop is blocked by computationworker_threads, ideally via a pool (Piscina).
  • You need to shell out to a non-Node binary, or want a hard fault boundarychild_process.
  • You're running a Node.js HTTP server on a VM or bare-metal box with multiple cores and no orchestratorcluster.
  • You're already on Kubernetes/ECS/similar → let the orchestrator's replica count do the horizontal scaling; use worker_threads inside each replica for CPU-bound work if needed.

FAQ

Can worker threads and cluster be used together?

Yes. It's common to run cluster (or container replicas) for horizontal scaling across cores, with each server instance internally using a worker_threads pool for CPU-bound tasks. They're not mutually exclusive — they operate at different levels.

Do worker threads actually run in true parallel, or is it just concurrency?

True parallel execution. Each worker thread runs on its own OS thread with its own V8 isolate and, in modern Node, can execute simultaneously with the main thread and other workers on multi-core hardware — this is different from the single-threaded concurrency the main event loop provides for I/O.

Is the cluster module deprecated or going away?

No, it's stable and maintained. It's simply less necessary than it was before containerized deployments with orchestrator-managed replicas became the norm, so newer greenfield services increasingly skip it in favor of letting the platform handle horizontal scaling.

What's the simplest way to offload a CPU-heavy function without hand-rolling worker management?

Use a worker pool library like Piscina rather than creating Worker instances manually per request — it handles pooling, queuing, and backpressure so you don't build that infrastructure yourself.

Sources