Node.js runs your JavaScript on a single thread. That is a good fit for servers that spend most of their time waiting on databases and networks. It is a poor fit when one request needs to compress an image or hash a large payload, because while that work runs, every other request waits.
Node.js gives you two built-in ways to use more than one CPU core: the cluster module and worker_threads. They look similar and solve different problems. This guide explains how each works and how to choose.
The Problem: One Thread, Many Cores
A modern server has several CPU cores. A default Node.js process uses one of them for JavaScript. Two different things can go wrong:
- Too many requests for one core. Each request is short, but there are thousands per second, and a single event loop cannot keep up.
- One request that blocks the loop. A single CPU-heavy task freezes everything else for its duration.
The first problem is about throughput. The second is about latency. Cluster addresses the first, and worker threads address the second.
What Is the Cluster Module?
The cluster module starts several copies of your application as separate operating system processes. One primary process forks workers, and the workers share the same server port. The primary distributes incoming connections between them.
import cluster from 'node:cluster'
import { availableParallelism } from 'node:os'
import { createServer } from 'node:http'
if (cluster.isPrimary) {
const count = availableParallelism()
for (let i = 0; i < count; i++) cluster.fork()
cluster.on('exit', (worker) => {
console.log(`Worker ${worker.process.pid} exited, starting another`)
cluster.fork()
})
} else {
createServer((req, res) => {
res.end(`Handled by ${process.pid}\n`)
}).listen(3000)
}
Each worker is a full Node.js process with its own memory, its own V8 instance and its own event loop. A crash in one worker does not take down the others.
What Are Worker Threads?
Worker threads start extra threads inside one process. Each thread has its own event loop and V8 instance, but threads live in the same process and can share memory through SharedArrayBuffer.
// main.js
import { Worker } from 'node:worker_threads'
export function runInWorker(input) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./hash-worker.js', import.meta.url), {
workerData: input,
})
worker.once('message', resolve)
worker.once('error', reject)
})
}
// hash-worker.js
import { parentPort, workerData } from 'node:worker_threads'
import { pbkdf2Sync } from 'node:crypto'
const result = pbkdf2Sync(workerData, 'salt', 600_000, 64, 'sha512')
parentPort.postMessage(result.toString('hex'))
The main thread stays free to answer requests while the worker does the heavy computation.
Side by Side
| Cluster | Worker threads | |
|---|---|---|
| Unit | Separate process | Thread inside one process |
| Memory | Isolated per worker | Isolated by default, can share a SharedArrayBuffer |
| Communication | IPC between processes | postMessage, or shared memory |
| Startup cost | Higher | Lower |
| Crash isolation | A crash stays in one worker | An uncaught error is contained to the thread, but a native crash can end the process |
| Shares a server port | Yes | No, the main thread owns the port |
| Best for | Scaling request throughput | Offloading CPU-bound tasks |
When to Use the Cluster Module
Use cluster when the work per request is small and you need more of it done at once. A typical API server handling many short requests is the textbook case.
Cluster has real limits:
- Memory is not shared. An in-memory cache in one worker is invisible to the others. State that must be shared belongs in Redis or a database.
- Sticky state breaks. WebSocket sessions or in-memory login sessions need a shared store or sticky routing.
- Memory use multiplies. Four workers use roughly four times the base memory.
In production, many teams skip the cluster module and run several containers or processes behind a load balancer, or use a process manager such as PM2. The effect is the same, and the orchestrator handles restarts and scaling.
When to Use Worker Threads
Use worker threads when a task is CPU-bound:
- Hashing, encryption or compression
- Image resizing or PDF generation
- Parsing large JSON or CSV payloads
- Running a calculation that takes tens of milliseconds or more
Worker threads do not speed up I/O. Reading a file or calling a database is already non-blocking in Node.js, so moving it to a thread adds overhead and no benefit.
Creating a thread per task is costly. For repeated work, keep a pool of workers and send tasks to them. A library such as piscina provides one, and writing your own pool is rarely worth it.
Can You Use Both?
Yes, and the combination is common. A cluster of processes, one per core, handles the request volume. Inside each process, a small worker pool handles the occasional heavy task. Keep the total thread count near your core count, or the two layers compete for the same CPUs.
Common Mistakes
- Using worker threads for database calls. The event loop already handles them well.
- Sending huge objects through
postMessage. Messages are copied with the structured clone algorithm. For large binary data, transfer anArrayBufferinstead of copying it. - Keeping state in memory under cluster. The next request may land on a different worker.
- Spawning a new worker for every request. Startup cost adds up. Reuse workers through a pool.
- Not restarting crashed cluster workers. Listen for
exitand fork a replacement.
A Simple Rule
If the problem is too many requests, run more processes with cluster or a load balancer. If the problem is one slow task blocking the loop, move that task to a worker thread. Measure first. A profile will show whether the event loop is blocked or whether the bottleneck is somewhere else entirely, such as a slow query.
Summary
Cluster scales a Node.js server across cores by running several isolated processes. Worker threads keep a single process responsive by moving CPU-heavy work off the main thread. They answer different questions, and most services need one of them, not both.
