Skip to content
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.