Scaling with the cluster Module
Reviewed & published by Brayan K
The cluster module lets a single Node application fork itself into multiple worker processes — one per CPU core — that all share the same server port, so your server can use the whole machine instead of just one core.
Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll learn why one Node process only ever uses one core, how the primary/worker model and cluster.fork() spread load across all of them, how workers share a single port, how cluster differs from worker_threads, and why PM2's cluster mode is the practical way to do this in production.
What You'll Learn in This Lesson
- Why a single Node process uses only one CPU core
- How cluster.fork() spawns one worker process per core
- The primary/worker model and how it distributes connections
- How every worker shares a single listening port without EADDRINUSE
- How cluster differs from worker_threads, and when to use each
- Why PM2's cluster mode is the production-friendly way to scale
1️⃣ One Process = One Core
Node runs your JavaScript on a single thread, inside a single process, on a single CPU core. That's by design — the event loop is what makes async I/O so simple. But it means a server with 4 or 16 cores still funnels everything through one of them, leaving the rest idle even under heavy load.
The first step in scaling is simply knowing how many cores you have. os.cpus() returns an array with one entry per logical core, so os.cpus().length is the number of workers you'd typically fork.
// One Node process runs your JavaScript on ONE CPU core.
// os.cpus() tells you how many cores are available to spread work across.
const os = require('node:os');
const cores = os.cpus().length;
console.log(`Logical CPU cores available: ${cores}`);
console.log(`A single Node process uses 1 core, leaving ${cores - 1} idle.`);
console.log(`The cluster module lets you fork 1 worker per core to use them all.`);2️⃣ The Primary/Worker Model
The cluster module splits your program into two roles. The primary process doesn't serve requests — it forks workers (one per core) with cluster.fork() and keeps them alive, re-forking any that crash. Each worker is a full copy of your app that actually runs the HTTP server. The same file runs in both roles; cluster.isPrimary tells you which one you are.
The clever part is the shared port. Every worker calls server.listen(3000), yet there's no "address in use" error: the primary owns the real socket and distributes incoming connections to the workers (round-robin by default on Linux/macOS).
// The canonical cluster server. Run it with: node server.js
const cluster = require('node:cluster');
const http = require('node:http');
const os = require('node:os');
if (cluster.isPrimary) {
// The PRIMARY process forks one worker per CPU core.
const cpus = os.cpus().length;
console.log(`Primary ${process.pid} forking ${cpus} workers`);
for (let i = 0; i < cpus; i++) cluster.fork();
// If a worker dies, replace it so capacity stays constant.
cluster.on('exit', (worker) => {
console.log(`Worker ${worker.process.pid} died — forking a replacement`);
cluster.fork();
});
} else {
// Each WORKER runs the actual server. They all share port 3000:
// the primary owns the socket and hands connections to the workers.
http.createServer((req, res) => {
res.end(`Handled by worker ${process.pid}\n`);
}).listen(3000);
console.log(`Worker ${process.pid} started`);
}3️⃣ How Round-Robin Dispatch Works
You can't easily watch the primary's scheduler from a single curl, so here's a faithful, runnable model of it. The primary keeps a pointer to the "next" worker and advances it for each connection, wrapping back to the first worker after the last one. That's the whole round-robin algorithm — and you can reproduce the exact output below.
// A stand-in for how the cluster PRIMARY shares one port across workers.
// Real cluster does this in C++; here we simulate the round-robin scheduler
// that the primary uses to hand each incoming connection to the next worker.
const WORKERS = 4; // pretend we forked 4 workers (one per core)
const handled = [0, 0, 0, 0]; // requests handled by each worker
let next = 0; // round-robin pointer
function dispatch(requestId) {
const worker = next;
handled[worker]++;
console.log(`Request #${requestId} -> Worker ${worker} (pid sim ${1000 + worker})`);
next = (next + 1) % WORKERS; // advance, wrapping back to 0
}
// 8 incoming requests get spread evenly across the 4 workers.
for (let i = 1; i <= 8; i++) dispatch(i);
console.log('Totals per worker:', handled);Your turn. The round-robin picker below works once you fill in the single blank marked ___. Follow the 👉 hint, then run it and compare with the expected output.
// 🎯 YOUR TURN — replace each ___ then run it.
const WORKERS = 3;
let next = 0;
function pickWorker() {
const chosen = next;
// advance the pointer, wrapping back to 0 after the last worker
next = (next + 1) % ___; // 👉 which constant keeps it in range? (WORKERS)
return chosen;
}
console.log(pickWorker()); // 0
console.log(pickWorker()); // 1
console.log(pickWorker()); // 2
console.log(pickWorker()); // 0 (wrapped around)
// ✅ Expected output:
// 0
// 1
// 2
// 0Mini-Challenge: Report Your Cluster Plan
No blanks this time — just a brief and an outline. Write a small script that reads your core count and prints how many workers you'd fork, the worker labels, and the shared port. Run it and compare with the example output.
// 🎯 MINI-CHALLENGE: report your cluster plan
// 1. Read the CPU count with os.cpus().length.
// 2. Print "Primary process would fork <N> workers."
// 3. Build an array of labels worker-0 .. worker-(N-1) and print them joined by ", ".
// 4. Print "All <N> share port 3000 via the primary."
//
// ✅ Example output (your N depends on your machine — 4 cores shown here):
// Primary process would fork 4 workers.
// Workers: worker-0, worker-1, worker-2, worker-3
// All 4 share port 3000 via the primary.
const os = require('node:os');
// your code hereCommon Errors (and the fix)
- ❌ Sharing state in a variable across workers — an in-memory counter or cache lives in one worker only; the others never see it. ✅ Put shared state in Redis or a database that every worker can reach.
- ❌ "EADDRINUSE: address already in use" — you bound the port in the primary too, or another process already holds it. ✅ Only the workers should call listen(); the primary just forks. Make sure nothing else is on the port.
- ❌ Workers crash and never come back — you forgot the cluster.on('exit', ...) handler, so a crashed worker stays dead and capacity drops. ✅ Re-fork on exit, or let PM2 do it for you.
- ❌ Using cluster for CPU-bound math — forking whole processes is heavy and they don't share memory. ✅ For pure computation, prefer worker_threads; use cluster to scale a server.
- ❌ Expecting cluster.isPrimary on old Node — older code used cluster.isMaster. ✅ On modern Node use isPrimary (the old name still works but is deprecated).
Pro Tips
- 💡 Let PM2 do the forking: pm2 start app.js -i max launches one worker per core, restarts crashes, and reloads with zero downtime — no cluster boilerplate.
- 💡 Keep workers stateless: store sessions, caches, and counters in Redis so any worker can serve any request.
- 💡 cluster scales servers, worker_threads scale CPU work: reach for the right one — they are not interchangeable.
📋 Quick Reference — cluster vs worker_threads
| Concept | Detail |
|---|---|
| Fork a worker | cluster.fork() |
| Am I the primary? | cluster.isPrimary |
| Re-fork on crash | cluster.on('exit', ...) |
| Count cores | os.cpus().length |
| Scale a server | cluster (separate processes) |
| Scale CPU work | worker_threads (shared memory) |
| Production | pm2 start app.js -i max |
🎉 Lesson Complete!
- ✅ One Node process uses one CPU core; os.cpus().length tells you how many you have
- ✅ cluster.fork() spawns one worker process per core
- ✅ The primary distributes connections to workers, round-robin by default
- ✅ Workers share one port because the primary owns the socket
- ✅ Workers don't share memory — put shared state in Redis or a database
- ✅ Use cluster to scale servers and worker_threads for CPU work; PM2 automates cluster mode in production
Practice quiz
How many CPU cores does a single Node process use for your JavaScript?
- All of them
- Two
- One
- Half
Answer: One. One Node process runs your JavaScript on a single CPU core, leaving the others idle.
What does the cluster module let you do?
- Fork worker processes, often one per core, that share a port
- Create threads that share memory
- Run code on the GPU
- Compress HTTP responses
Answer: Fork worker processes, often one per core, that share a port. cluster forks worker processes (commonly one per core) that all share the same server port.
Which property tells you the number of logical CPU cores?
- process.cores
- cluster.workers.size
- os.cores
- os.cpus().length
Answer: os.cpus().length. os.cpus() returns an array of cores; its .length is the count you can spread work across.
In the primary/worker model, what does the primary process do?
- Handles every HTTP request itself
- Forks workers and replaces them when they die
- Shares variables with workers
- Runs the database
Answer: Forks workers and replaces them when they die. The primary forks one worker per core and, on the 'exit' event, forks a replacement to keep capacity constant.
Do clustered workers share memory or variables?
- No, each worker is a separate process with its own memory
- Yes, all variables are shared
- Only numbers are shared
- Yes, through global scope
Answer: No, each worker is a separate process with its own memory. Each worker is a separate OS process with isolated memory; shared state must live in Redis or a database.
How do all workers listen on the same port without EADDRINUSE?
- Each worker binds a different port internally
- Node disables the port check
- The primary binds the socket and hands connections to workers
- Workers take turns rebinding
Answer: The primary binds the socket and hands connections to workers. Only the primary truly binds the port; the cluster module hands workers a reference to that shared socket.
How does the primary distribute connections by default on Linux/macOS?
- Random
- Round-robin
- Least-connections
- First worker only
Answer: Round-robin. By default the primary uses a round-robin scheduler to hand each connection to the next worker.
What is the key difference between cluster and worker_threads?
- They are identical
- worker_threads scales servers; cluster does CPU work
- cluster shares memory; worker_threads does not
- cluster forks processes to scale a server; worker_threads run threads for CPU work
Answer: cluster forks processes to scale a server; worker_threads run threads for CPU work. cluster scales a network server across cores; worker_threads offloads CPU-heavy work without blocking the main thread.
If each worker keeps its own request counter, what is true?
- All workers share one counter
- Each counts only its own requests, not a global total
- The primary sums them automatically
- The counter resets every request
Answer: Each counts only its own requests, not a global total. Because memory is not shared, each worker counts only its own requests; a global total needs external storage.
What does running pm2 start app.js -i max do?
- Runs a single process only
- Disables clustering
- Launches one worker per core in cluster mode with auto-restart
- Builds the project
Answer: Launches one worker per core in cluster mode with auto-restart. PM2's cluster mode wraps cluster.fork(), launching one worker per core and restarting crashed workers.
Continue this course
- Previous: Checkpoint: Build a REST API
- Next: WebSockets (ws & Socket.IO) — Push real-time updates with persistent two-way connections
- Quick reference: Node.js cheat sheet
Frequently asked questions
What is the difference between the cluster module and worker_threads?
They solve different problems. cluster forks separate Node processes that each have their own memory, V8 instance, and event loop, and the primary shares one server port across them — it's the right tool for scaling a network server (like an HTTP API) across all CPU cores. worker_threads creates threads inside a single process that share memory through SharedArrayBuffer and are far cheaper to spawn — they're the right tool for offloading CPU-heavy computation (parsing, hashing, image work) without blocking the main event loop. Rule of thumb: cluster to scale a server across cores, worker_threads to move a heavy calculation off the main thread.
Do clustered workers share memory or variables?
No. Each worker is a completely separate operating-system process with its own memory, so a variable, counter, or in-memory cache in one worker is invisible to the others. If you increment a request counter, each worker counts only its own requests. Any state that must be shared — sessions, caches, rate-limit counters — has to live in something external that all workers can reach, such as Redis or a database. Workers can also pass messages to the primary with process.send(), but that's for coordination, not shared state.
How do all the workers listen on the same port without an EADDRINUSE error?
The primary process is the one that actually binds the listening socket. When a worker calls server.listen(3000), the cluster module intercepts it and, instead of binding a second time, hands the worker a reference to the primary's shared socket. The primary then distributes incoming connections to the workers (round-robin by default on Linux and macOS). Because only the primary truly binds the port, you avoid the EADDRINUSE 'address already in use' error you'd get if each process tried to bind 3000 itself.
Should I use the cluster module directly or a process manager like PM2?
For production, most teams reach for PM2 (or a container orchestrator) rather than hand-writing cluster code. Running pm2 start app.js -i max launches one worker per CPU core in cluster mode, restarts crashed workers, enables zero-downtime reloads, and gives you logs and metrics — all the boilerplate you'd otherwise write around cluster.fork() and the exit handler. Writing cluster code by hand is great for understanding how scaling works, but PM2's cluster mode is essentially a battle-tested wrapper around this exact module, so it's the pragmatic choice for real deployments.