Skip to content
Node.js nd workers 5 min read

Cluster vs Worker Threads vs child_process

Node.js gives you three distinct tools for going beyond a single thread, and picking the wrong one is one of the most common scaling mistakes. The node:cluster module forks whole processes to scale I/O-bound servers across CPU cores; node:worker_threads runs parallel JavaScript inside one process for CPU-bound computation; and node:child_process launches external programs or isolated Node scripts. They look similar from a distance, but they solve genuinely different problems. This page compares them side by side and gives you a decision guide.

The three models at a glance

Each model trades off isolation, communication cost, and what kind of work it accelerates. The key question is always: what is my bottleneck? If requests are waiting on the network or disk, you need more processes handling connections. If the CPU itself is pegged by a heavy computation, you need parallel JavaScript. If you need to run a different program entirely, you need a child process.

ModelModuleUnitBest forMemory
Clusternode:clusterForked processes sharing a portScaling I/O-bound servers across coresIsolated per process
Worker threadsnode:worker_threadsThreads in one processCPU-bound JavaScript inside your appSharable via SharedArrayBuffer
Child processnode:child_processSeparate OS processRunning external programs / scriptsFully isolated

Rule of thumb: cluster scales throughput, worker threads scale computation, child processes run things outside your program. Mixing these up — for example reaching for workers to “speed up” an HTTP server — leads to slower, more complex code.

Cluster: scale an I/O-bound server

The cluster module forks your application into multiple worker processes, each a full copy of your app, and lets them share a single listening socket. The OS load-balances incoming connections across them, so an 8-core machine can handle roughly 8x the concurrent request load of a single Node process. This is the right tool for a typical web API, where each request mostly waits on databases and downstream services.

// server.js
import cluster from 'node:cluster';
import http from 'node:http';
import { availableParallelism } from 'node:os';
import process from 'node:process';

if (cluster.isPrimary) {
  const cpus = availableParallelism();
  console.log(`Primary ${process.pid} forking ${cpus} workers`);
  for (let i = 0; i < cpus; i++) cluster.fork();

  cluster.on('exit', (worker) => {
    console.log(`Worker ${worker.process.pid} died; restarting`);
    cluster.fork();
  });
} else {
  http
    .createServer((req, res) => res.end(`Handled by PID ${process.pid}\n`))
    .listen(3000);
  console.log(`Worker ${process.pid} listening`);
}

Output:

Primary 51200 forking 8 workers
Worker 51201 listening
Worker 51202 listening
...

Because every worker is a separate process, a crash in one does not take down the others — the primary simply forks a replacement. The trade-off is that processes share nothing: in-memory state, caches, and sessions must live in an external store like Redis.

Worker threads: parallel CPU work

Worker threads do not help with I/O. They exist to move a heavy synchronous computation off the main thread so the event loop stays responsive. Each worker is a real OS thread with its own V8 isolate, communicating through fast message passing and, optionally, shared memory.

// hash-worker.js
import { parentPort, workerData } from 'node:worker_threads';
import { pbkdf2Sync } from 'node:crypto';

const key = pbkdf2Sync(workerData, 'salt', 200_000, 64, 'sha512');
parentPort.postMessage(key.toString('hex'));
// main.js
import { Worker } from 'node:worker_threads';

function hash(password) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(new URL('./hash-worker.js', import.meta.url), {
      workerData: password,
    });
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

console.log(await hash('s3cret'));

The expensive pbkdf2Sync call would block the event loop if run inline; inside a worker it runs in parallel while the main thread keeps serving requests.

Child process: run external commands

Use node:child_process when the work lives outside your JavaScript — invoking ffmpeg, running a Python script, calling a shell utility, or spinning up a fully isolated Node process for sandboxing. Communication happens over stdio streams or IPC, which is slower than thread message passing but gives total isolation.

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const run = promisify(execFile);
const { stdout } = await run('ffprobe', ['-version']);
console.log(stdout.split('\n')[0]);

Output:

ffprobe version 6.1.1 Copyright (c) 2007-2024 the FFmpeg developers

Prefer execFile or spawn with an argument array over exec, which runs a shell and is vulnerable to command injection when arguments come from user input.

Decision guide

Your situationReach for
HTTP/API server bottlenecked on concurrent connectionsCluster (or a process manager like PM2)
Heavy synchronous JS: hashing, image resizing, parsingWorker threads
Need to run ffmpeg, a shell command, or another binarychild_process (spawn / execFile)
Need crash isolation for untrusted or risky codechild_process (separate process)
Sharing large binary buffers between parallel tasksWorker threads with SharedArrayBuffer
Plain network or disk waitingNone — just async/await on the main thread

In practice these combine. A production API often runs under cluster (one process per core) and inside each worker process uses a small pool of worker threads for the occasional CPU-heavy endpoint, while shelling out to child processes for media conversion. Each layer addresses a different bottleneck.

Best practices

  • Diagnose the bottleneck before choosing: I/O concurrency points to cluster, CPU saturation to worker threads.
  • Use cluster (or PM2) for HTTP servers and keep all shared state in an external store like Redis or Postgres.
  • Reserve worker threads for genuinely CPU-bound JavaScript; never wrap async I/O in a worker.
  • Pool both worker threads and child processes rather than spawning one per task — startup cost is significant.
  • Always handle exit/error events and restart dead cluster workers and child processes to stay fault-tolerant.
  • Avoid the exec and eval-style shell paths with untrusted input; pass arguments as an array to spawn/execFile.
  • Cap parallelism near os.availableParallelism(); more processes or threads than cores just causes contention.
Last updated June 14, 2026
Was this helpful?