Skip to content
C Codeloom
Node.js

Node.js Process vs Thread vs Cluster

Understand when to reach for child processes, worker threads, or the cluster module to scale Node.js workloads.

·4 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • Why Node.js is single-threaded
  • When to use child_process
  • When to use worker_threads
  • When to use cluster
  • Costs and tradeoffs

Prerequisites

  • Familiar with Node.js basics

What and Why

Node.js runs your JavaScript on a single thread. That sounds limiting, but for IO-bound work it is actually a strength: no locks, no context switches, no shared-state bugs. The event loop dispatches IO to libuv’s thread pool and your code keeps moving.

The trouble starts when work is CPU-bound. A long synchronous loop blocks the loop, requests pile up, and the server stops responding. Node provides three escape hatches: child_process, worker_threads, and cluster. They look similar but solve different problems.

Mental Model

  • child_process spawns a separate OS process with its own V8 instance. Best for shelling out to other programs or isolating untrusted code.
  • worker_threads spawns a thread inside the same process, sharing the same Node binary but with its own event loop and V8 isolate. Best for offloading CPU work while still sharing memory via SharedArrayBuffer.
  • cluster forks worker processes that share a server port. The OS round-robins or hashes connections among them. Best for using all CPU cores for an HTTP server.

Pick based on the goal: separate program (child_process), parallel computation (worker_threads), parallel request handling (cluster).

Hands-on Example

A CPU-bound prime check that would block the loop if run inline.

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

function isPrime(n) {
  if (n < 2) return false;
  for (let i = 2; i * i <= n; i++) if (n % i === 0) return false;
  return true;
}

parentPort.postMessage(isPrime(workerData));

Call it without blocking the event loop.

import { Worker } from 'node:worker_threads';

function checkPrime(n) {
  return new Promise((resolve, reject) => {
    const w = new Worker(new URL('./prime-worker.js', import.meta.url), {
      workerData: n,
    });
    w.on('message', resolve);
    w.on('error', reject);
    w.on('exit', (code) => code !== 0 && reject(new Error(`exit ${code}`)));
  });
}

console.log(await checkPrime(9_999_999_967));

Or use cluster to spread an HTTP server across cores.

import cluster from 'node:cluster';
import { cpus } from 'node:os';
import http from 'node:http';

if (cluster.isPrimary) {
  for (let i = 0; i < cpus().length; i++) cluster.fork();
  cluster.on('exit', () => cluster.fork());
} else {
  http.createServer((req, res) => {
    res.end(`pid ${process.pid}`);
  }).listen(3000);
}

For shelling out to ffmpeg or git, child_process.spawn is the right pick.

import { spawn } from 'node:child_process';
const p = spawn('git', ['rev-parse', 'HEAD']);
for await (const chunk of p.stdout) process.stdout.write(chunk);
child_process -> new OS process -> own memory -> IPC via pipes
worker_threads -> new thread in same process -> shared memory possible
cluster        -> forked processes sharing a port -> OS distributes conns
Three ways to escape one thread

Common Pitfalls

  • Reaching for clustering when you really need workers. If one request does heavy CPU, clustering only helps if other requests can land on other workers. A truly long-running computation still blocks one worker.
  • Sharing big objects between threads. postMessage clones by default. For large buffers, use transferList to move ownership without copying.
  • Treating workers as free. Spawning a worker per task is expensive. Use a pool with piscina or roll your own.
  • Forgetting exit handlers. Workers can die unexpectedly. Always handle exit and decide whether to restart.
  • Using cluster behind a proxy that already balances. If your load balancer is fronting many containers, you may not need cluster at all.

Practical Tips

  • Profile first. A CPU flame graph tells you whether you really need parallelism or just a smarter algorithm.
  • Use a worker pool sized to os.availableParallelism(). More workers than cores rarely helps and hurts cache locality.
  • For large data transfers, prefer SharedArrayBuffer plus Atomics to avoid copies.
  • In Kubernetes, prefer one Node process per pod over cluster. The platform already handles scaling, restarts, and load balancing.
  • Reach for child_process when isolation matters more than performance, like running user-provided code.

Wrap-up

Node.js gives you three concurrency primitives because they solve three different problems. Use child_process for shelling out and isolation. Use worker_threads for parallel CPU work in the same process. Use cluster to fill a multi-core box with one Node app. Match the tool to the goal, measure before and after, and you can keep Node’s single-threaded simplicity for most of your code while still scaling when it matters.