Node.js Performance: Clustering, Worker Threads, and What Actually Helps
Real benchmarks of the cluster module and worker threads on Node 10, plus event loop lag checks, PM2 cluster mode and when neither helps.

Sooner or later, someone asks why a Node.js API is slow and whether it needs more threads. Here I write about what actually makes a Node.js API faster: measuring the event loop first, then the cluster module, and finally worker threads, which are still experimental in Node 10 and 11. Every number below comes from a real benchmark on my machine.
The examples use Node 10.13 LTS, Express 4.16 and autocannon for load. I run the test suite before and after each change. A faster route that returns the wrong answer is not an improvement.
One thread runs your JavaScript
Node runs all your JavaScript on a single thread, the event loop. Network and disk I/O happen elsewhere, so one process can hold thousands of open requests. However, CPU work is different. While a handler hashes a password or parses a huge JSON file, nothing else runs. Every other request waits.
So the first question is never “how do I add threads”. Instead, it is “where is my time going”. Worker threads and clustering only help with one kind of problem, and I want proof before I reach for them.
A benchmark that shows the problem
This small Express app has two routes that do the same work. One blocks the event loop, and the other hands the work to libuv:
const express = require('express');
const crypto = require('crypto');
const app = express();
app.get('/hash-sync', (req, res) => {
const key = crypto.pbkdf2Sync('secret', 'salt', 100000, 32, 'sha256');
res.json({ key: key.toString('hex') });
});
app.get('/hash-async', (req, res, next) => {
crypto.pbkdf2('secret', 'salt', 100000, 32, 'sha256', (err, key) => {
if (err) return next(err);
res.json({ key: key.toString('hex') });
});
});
app.get('/healthz', (req, res) => res.json({ status: 'ok' }));
app.listen(3000);
Then I load each route with 10 connections for 10 seconds:
npx autocannon -c 10 -d 10 http://localhost:3000/hash-sync
The results, as averages:
| Setup | Requests per second |
|---|---|
/hash-sync, one process |
30 |
/hash-async, one process |
117 |
/hash-sync, cluster of 4 workers |
123 |
Worse, while /hash-sync was under load, a separate client hit /healthz. Its median latency was 589 ms for a route that does nothing. In production, that is how a load balancer decides a healthy server is dead. Averages also hide the slowest requests, so I track p99 latency alongside them.
Why is the async route four times faster? Because crypto.pbkdf2 runs on the libuv thread pool, which has 4 threads by default. You can raise it with the UV_THREADPOOL_SIZE environment variable, up to 128. That pool is shared with fs, dns.lookup and zlib, though.
Measure event loop lag first
Before changing anything, I measure how late the event loop runs. Node 10 has no built-in metric for this, but a timer does the job:
let last = Date.now();
let maxLag = 0;
setInterval(() => {
const now = Date.now();
maxLag = Math.max(maxLag, now - last - 500);
last = now;
}, 500).unref();
The timer should fire every 500 ms. Any extra delay is time the loop spent busy. During the sync benchmark, maxLag reached 178 ms. I expose the value on the health route and graph it. If lag stays near zero while the API is slow, the bottleneck is the database or a remote call, and no amount of threads will help.
For deeper digging, Clinic.js runs your app under load and points at the slow functions. Also, node --prof writes a V8 profile that node --prof-process turns into a readable report.
The cluster module: one process per core
The simplest way to use every core is the cluster module. A master process forks workers, and they all share the same port:
const cluster = require('cluster');
const os = require('os');
if (cluster.isMaster) {
const n = Number(process.env.WORKERS) || os.cpus().length;
for (let i = 0; i < n; i++) cluster.fork();
cluster.on('exit', (worker, code, signal) => {
console.log(`worker ${worker.process.pid} died (${signal || code}), starting a new one`);
cluster.fork();
});
} else {
require('./server');
}
With four workers, the blocking route went from 30 to 123 requests per second. A crash now kills one worker instead of the whole service, and the master starts a replacement.
Still, clustering has costs. Each worker is a full process with its own memory, often 50 MB or more. Also, workers share nothing. In-memory caches, rate limits and sessions must move to Redis or the database. In production, I usually let PM2 cluster mode manage this with pm2 start server.js -i max. On Kubernetes, I often skip clustering and run more single-process pods instead. My guide to Kubernetes for backend engineers covers how those pods scale.
Worker threads in Node 10 and 11
Clustering adds processes for more requests. Worker threads solve a different problem: moving one heavy task off the event loop inside a single process. The worker_threads module arrived in Node 10.5, but it is still experimental. Without the flag, it does not even load:
$ node -e "require('worker_threads')"
Error: Cannot find module 'worker_threads'
With --experimental-worker, it works:
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
const crypto = require('crypto');
if (isMainThread) {
const started = Date.now();
const worker = new Worker(__filename, { workerData: 'secret' });
worker.on('message', (hash) => {
console.log('hash', hash.slice(0, 16), 'in', Date.now() - started, 'ms');
});
worker.on('error', (err) => console.error(err));
} else {
const out = crypto.pbkdf2Sync(workerData, 'salt', 500000, 32, 'sha256');
parentPort.postMessage(out.toString('hex'));
}
$ node --experimental-worker worker.js
hash 4a653147af558047 in 196 ms
With worker threads, the main thread stayed free for those 196 ms. That said, I do not run worker threads in production on Node 10 yet. The API can still change, and starting a worker for each request is slow. If you try them, create a small fixed pool at startup and send tasks to it.
Cluster or worker threads: how I choose
- The API is slow but event loop lag is low. Fix queries, add database indexes or cache. Neither tool helps.
- A built-in has an async version. Use it.
crypto.pbkdf2,zlib.gzipandfs.readFilealready use the thread pool. - You need more throughput on one machine. Use cluster or PM2.
- One rare task is CPU heavy. Try worker threads behind the flag, or move it to a separate job queue.
Common mistakes with worker threads and clustering
- Adding workers before measuring. Most slow APIs wait on I/O, not CPU.
- Using sync APIs in handlers.
fs.readFileSyncandpbkdf2Syncblock every request. - Keeping state in memory under cluster. Each worker sees only its own copy.
- Starting a new worker thread per request. Startup cost can erase the gain.
- Running more processes than cores. They then fight for the same CPU.
FAQ
Is Node.js single-threaded?
Your JavaScript runs on one thread. However, Node uses a libuv thread pool for file, DNS, crypto and zlib work, and the operating system handles network I/O.
Are worker threads ready for production?
Not yet in my view. In Node 10 and 11 they need the --experimental-worker flag. I use the cluster module or a job queue until they become stable.
How many cluster workers should I run?
Start with one per CPU core, from os.cpus().length. Then benchmark, because memory limits or other processes on the machine can lower the right number.
What is a good event loop lag?
Under normal load, it should stay in the low milliseconds. Sustained lag over 100 ms means something is blocking the loop.
Measure first, then pick the smallest fix that removes the bottleneck. Most of the time that means an async API or a better query, and only rarely worker threads.
Last updated on 31 October 2026