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.isPrimaryandos.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:
| Problem | Solution |
|---|---|
| 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.isPrimaryistruein the process you launched,falsein forked workers. (cluster.isMasteris deprecated and should not be used.)cluster.fork()spawns a worker viachild_process.fork(), so workers and the primary communicate over an IPC channel and can hand server handles back and forth.availableParallelism()fromnode:osreturns 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:
isMainThread—trueoutside a worker,falseinside one.new Worker(filename, options)— loads a JavaScript file as a thread.options.workerDatapasses a structured-clone copy of data to the worker.parentPort— the worker’sMessagePortback to the parent. Messages sent viaparentPort.postMessage()arrive asworker.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.
| Consideration | cluster | worker_threads |
|---|---|---|
| Primary use case | Scaling HTTP/TCP servers across cores | Parallel CPU-bound computation |
| Isolation | Full process isolation | Threads share the process |
| Shared memory | No (message passing only) | Yes (SharedArrayBuffer, transfers) |
| Crash containment | One worker dying doesn’t kill others | An unhandled error can crash the process |
| Overhead per unit | Higher (process + V8 instance) | Lower (threads share the V8 process) |
| Communication | process.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.
postMessageuses the structured clone algorithm; large objects are copied (unless you pass anArrayBufferin 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:
clusterfor horizontally scaling servers across every core, with round-robin load balancing and automatic worker restarts.worker_threadsfor running CPU-bound work in parallel within a process, communicating viapostMessage,workerData, andparentPort.
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
- Node.js
worker_threadsdocumentation — https://nodejs.org/api/worker_threads.html - Node.js
clusterdocumentation — https://nodejs.org/api/cluster.html - Node.js
os.availableParallelism()— https://nodejs.org/api/os.html#osavailableparallelism - Node.js
crypto(pbkdf2) — https://nodejs.org/api/crypto.html#cryptopbkdf2syncpassword-salt-iterations-keylen-digest - “Using
AsyncResourcefor aWorkerthread pool” — https://nodejs.org/api/async_context.html#using-asyncresource-for-a-worker-thread-pool