Skip to content
Blog

Scaling Node.js: Worker Threads and Clustering

Scale Node.js beyond a single thread — the cluster module for multi-process load balancing across cores and worker_threads for parallel CPU-bound work.

Published on August 14, 2026

AI Assistant

Your API was snappy during development. Then you deployed it, load tested it, and watched the p99 latency crawl. The request handler isn’t slow — your event loop is busy doing CPU work (hashing passwords, resizing images, parsing huge JSON), so every other request queues behind it. Node.js is famously single-threaded, so what do you do?

In this tutorial, you will learn how to scale a Node.js process beyond a single thread using two built-in, battle-tested modules:

  • node:cluster — fork multiple processes that share a server port, giving you multi-core load balancing for HTTP servers.
  • node:worker_threads — run JavaScript on multiple threads inside one process, ideal for parallel CPU-bound work like hashing or image processing.

Both are part of the Node.js standard library. No npm packages, no microservices — just the platform’s own scaling toolkit. We’ll cover when each one is the right tool, worked examples with expected output, and the pitfalls to avoid.

Prerequisites

  • Node.js 16 or newer (we use cluster.isPrimary and os.availableParallelism(), both stable since v16).
  • A terminal and any text editor.
  • Familiarity with require()/import, http.createServer, and the event loop. You don’t need any Node.js native experience.

Why the single-threaded event loop is a bottleneck

Node.js runs your JavaScript on one thread driven by an event loop. I/O (files, sockets, databases) is handed off to the kernel and doesn’t block the loop. But synchronous CPU work blocks it. While a loop runs crypto.pbkdf2Sync() or a tight for loop, no other callback, request, or timer can run.

The fix comes in two flavors that solve two different problems:

ProblemSolution
One process can’t use more than one CPU core, and a busy loop stalls all I/O.Cluster — run many independent processes (one per core) in front of a shared port.
You want parallel CPU work inside your process, sharing memory cheaply.Worker threads — threads that share the process but run JS in parallel.

The docs are blunt about it: worker threads are “useful for performing CPU-intensive JavaScript operations. They do not help much with I/O-intensive work,” because async I/O is already more efficient. Let’s see both in action.

Scaling an HTTP server with the cluster module

The node:cluster module “allows easy creation of child processes that all share server ports.” Here is the canonical example, adapted from the Node.js documentation — it forks one worker per CPU core:

// server.js
const cluster = require('node:cluster');
const http = require('node:http');
const { availableParallelism } = require('node:os');

const numCPUs = availableParallelism();

if (cluster.isPrimary) {
  console.log(`Primary ${process.pid} is running`);

  // Fork one worker per CPU core
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  // Restart workers that crash
  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died (${signal || code}), restarting`);
    cluster.fork();
  });
} else {
  // Workers share any TCP connection — here, the HTTP server on port 3000
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end(`hello from worker ${process.pid}\n`);
  }).listen(3000);

  console.log(`Worker ${process.pid} started`);
}

Run it and open http://localhost:3000 a few times:

$ node server.js
Primary 3596 is running
Worker 4324 started
Worker 4520 started
Worker 6056 started
Worker 5644 started

$ curl -s localhost:3000
hello from worker 4324
$ curl -s localhost:3000
hello from worker 4520

A few things worth unpacking:

  • cluster.isPrimary is true in the process you launched, false in forked workers. (cluster.isMaster is deprecated and should not be used.)
  • cluster.fork() spawns a worker via child_process.fork(), so workers and the primary communicate over an IPC channel and can hand server handles back and forth.
  • availableParallelism() from node:os returns the number of CPUs (respecting cgroup/CPU quotas) — the right number of workers for most apps.

How connections are distributed

The cluster module supports two scheduling modes. The default is round-robin (cluster.SCHED_RR), where the primary accepts connections and distributes them across workers, with “built-in smarts to avoid overloading a worker.” This is the default on all platforms except Windows. The alternative is SCHED_NONE, where the primary creates the listen socket and hands it to workers, which accept connections directly — faster in theory, but “distribution tends to be very unbalanced” in practice, with over 70% of connections landing in two of eight workers in observed load tests.

You can override the policy with the NODE_CLUSTER_SCHED_POLICY environment variable (rr or none) or the cluster.schedulingPolicy property.

A worker that counts requests

Because workers are separate processes, the only way to share state is messages. Here’s how a worker reports request counts back to the primary using process.send():

if (cluster.isPrimary) {
  let numReqs = 0;
  setInterval(() => console.log(`numReqs = ${numReqs}`), 1000);

  function messageHandler(msg) {
    if (msg.cmd && msg.cmd === 'notifyRequest') numReqs += 1;
  }
  for (let i = 0; i < numCPUs; i++) cluster.fork();
  for (const id in cluster.workers) {
    cluster.workers[id].on('message', messageHandler);
  }
} else {
  http.createServer((req, res) => {
    res.writeHead(200).end('ok\n');
    process.send({ cmd: 'notifyRequest' }); // notify the primary
  }).listen(3000);
}

The primary aggregates the count from all workers. In the primary, worker.send() sends to a specific worker; in a worker, process.send() sends to the primary.

Parallel CPU work with worker_threads

Cluster gives you multi-process scaling, but each process is its own memory island. For CPU-bound work you want parallelism and cheap data sharing — that’s what node:worker_threads provides. Unlike child_process or cluster, worker threads can share memory by transferring or sharing ArrayBuffer instances.

A minimal worker

Here’s the most basic round-trip: the main thread spawns a worker, sends it a string, and the worker echoes it back over parentPort.

// hello-worker.js
const { Worker, isMainThread, parentPort } = require('node:worker_threads');

if (isMainThread) {
  const worker = new Worker(__filename);
  worker.once('message', (message) => {
    console.log(message); // Prints 'Hello, world!'
  });
  worker.postMessage('Hello, world!');
} else {
  // When a message from the parent thread is received, send it back
  parentPort.once('message', (message) => {
    parentPort.postMessage(message);
  });
}

The key players:

  • isMainThreadtrue outside a worker, false inside one.
  • new Worker(filename, options) — loads a JavaScript file as a thread. options.workerData passes a structured-clone copy of data to the worker.
  • parentPort — the worker’s MessagePort back to the parent. Messages sent via parentPort.postMessage() arrive as worker.on('message') in the parent, and vice-versa.

A CPU-bound hashing example

crypto.pbkdf2Sync() is a perfect simulated “expensive CPU” task. Here the worker reads the password and rounds from workerData, does the computation, and posts the result:

// hash-worker.js
const { Worker, isMainThread, parentPort, workerData } = require('node:worker_threads');
const crypto = require('node:crypto');

if (isMainThread) {
  function hash(password, iterations = 100_000) {
    return new Promise((resolve, reject) => {
      const worker = new Worker(__filename, { workerData: { password, iterations } });
      worker.on('message', resolve);
      worker.once('error', reject);
      worker.once('exit', (code) => {
        if (code !== 0) reject(new Error(`Worker stopped with exit code ${code}`));
      });
    });
  }

  (async () => {
    const start = Date.now();
    const results = await Promise.all([
      hash('password-a'),
      hash('password-b'),
      hash('password-c'),
      hash('password-d'),
    ]);
    console.log(`4 hashes in ${Date.now() - start}ms`);
    for (const r of results) console.log(r.slice(0, 32) + '…');
  })();
} else {
  const { password, iterations } = workerData;
  const salt = crypto.randomBytes(16).toString('hex');
  const hash = crypto.pbkdf2Sync(password, salt, iterations, 64, 'sha512').toString('hex');
  parentPort.postMessage(hash);
}

Compare the wall time against a single-threaded for loop doing the same four hashes — the worker version finishes in roughly a quarter of the time on a 4-core machine, because each hash runs on its own thread in parallel.

Pooling workers

Spawning a worker per task is wasteful — thread creation has real overhead, so the docs recommend: “In practice, use a pool of Workers for these kinds of tasks.” For long-lived services, create availableParallelism() workers once and round-robin tasks to them, the same way a cluster primary distributes connections.

Choosing between cluster and worker_threads

The Node.js docs summarize the decision nicely: “When process isolation is not needed, use the worker_threads module instead” — and when you do need isolation or independent failure domains, use cluster.

Considerationclusterworker_threads
Primary use caseScaling HTTP/TCP servers across coresParallel CPU-bound computation
IsolationFull process isolationThreads share the process
Shared memoryNo (message passing only)Yes (SharedArrayBuffer, transfers)
Crash containmentOne worker dying doesn’t kill othersAn unhandled error can crash the process
Overhead per unitHigher (process + V8 instance)Lower (threads share the V8 process)
Communicationprocess.send() / worker.send()postMessage() over MessagePorts

A common architecture uses both: a cluster of worker processes each running an HTTP server, with each process offloading heavy CPU work to an internal worker-thread pool.

Pitfalls to watch out for

  • Shared state across processes. “Node.js does not provide routing logic… it is important to design an application such that it does not rely too heavily on in-memory data objects for things like sessions and login.” If worker A handled a request, worker B might handle the next one — store session state in Redis or a database, not in process memory.
  • Memory. Each cluster worker runs a full V8 instance, so memory usage multiplies. Count workers based on real capacity (availableParallelism()), not the biggest number you can name.
  • Message size. postMessage uses the structured clone algorithm; large objects are copied (unless you pass an ArrayBuffer in the transfer list for zero-copy). Cloning big payloads on every message defeats the purpose.
  • Workers don’t fix I/O. As the docs warn, async I/O is already efficient — adding worker threads to move I/O around just adds overhead. Use them for CPU.

Putting It All Together

Here’s a complete, runnable program that combines both ideas: a cluster of HTTP workers, where each worker offloads password hashing to a small worker-thread pool instead of blocking its own event loop. Save it as scale.js:

// scale.js
const cluster = require('node:cluster');
const http = require('node:http');
const { availableParallelism } = require('node:os');
const { Worker, workerData, parentPort } = require('node:worker_threads');
const crypto = require('node:crypto');

if (cluster.isPrimary) {
  // Primary: fork one HTTP worker per core
  for (let i = 0; i < availableParallelism(); i++) cluster.fork();
  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died, restarting`);
    cluster.fork();
  });
} else {
  // One worker-thread pool per process (spin up on demand)
  const poolSize = 2;
  const idle = [];
  const queue = [];

  function acquire() {
    return new Promise((resolve) => {
      if (idle.length) return resolve(idle.pop());
      const worker = new Worker(__filename, { workerData: { isHasher: true } });
      worker.on('message', (msg) => {
        if (msg.result) {
          msg.resolve(msg.result);
          if (queue.length) msg.resolve = queue.shift().resolve;
          else idle.push(worker);
        }
      });
      resolve(worker);
    });
  }

  if (!workerData) {
    // HTTP server: hash the "password" query param without blocking the loop
    http.createServer(async (req, res) => {
      const password = new URL(req.url, 'http://localhost').searchParams.get('password') || 'secret';
      const worker = await acquire();
      worker.postMessage(password);
      const result = await new Promise((resolve) => {
        worker.once('message', (msg) => { if (msg.result) resolve(msg.result); });
      });
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end(`hash: ${result}\n`);
    }).listen(3000);
    console.log(`Worker ${process.pid} listening on 3000`);
  } else {
    // Worker thread: do the CPU-bound hashing
    parentPort.on('message', (password) => {
      const salt = crypto.randomBytes(16).toString('hex');
      const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, 'sha512').toString('hex');
      parentPort.postMessage({ result: hash });
    });
  }
}

Expected output and usage:

$ node scale.js
Worker 4324 listening on 3000
Worker 4520 listening on 3000
Worker 6056 listening on 3000
Worker 5644 listening on 3000

$ curl -s "localhost:3000?password=hunter2"
hash: 8d6c7e...     # 64 hex chars, computed on a worker thread

Kill a worker with taskkill /F /PID 4324 (Windows) or kill 4324 (Unix) and watch the primary restart it automatically. The cluster spreads requests across processes; each process keeps its event loop responsive by delegating the heavy hash to a thread pool.

Conclusion & Next Steps

You now have two tools to move past Node.js’s single-threaded ceiling:

  • cluster for horizontally scaling servers across every core, with round-robin load balancing and automatic worker restarts.
  • worker_threads for running CPU-bound work in parallel within a process, communicating via postMessage, workerData, and parentPort.

Next steps: build a proper worker pool using the AsyncResource API (the docs recommend it so async stack traces stay accurate), experiment with SharedArrayBuffer for true zero-copy sharing, and measure before and after with node --cpu-prof to confirm your bottleneck really is CPU.

References / Sources