A Node.js process runs your JavaScript on one thread, so it uses roughly one CPU core for application code. On a 4-vCPU VPS, a single node server.js leaves most of the CPU idle under load. Cluster mode runs several copies of your app (one per core), and spreads incoming connections across them.
Option 1: PM2 cluster mode (easiest)
sudo npm install -g pm2
pm2 start server.js --name api -i max # one instance per CPU core
# or a fixed number:
pm2 start server.js --name api -i 3
pm2 list
pm2 monit
With an ecosystem file:
// ecosystem.config.js
module.exports = {
apps: [{
name: "api",
script: "server.js",
instances: "max", // or a number
exec_mode: "cluster",
max_memory_restart: "400M",
env: { NODE_ENV: "production", PORT: 3000 },
}],
};
pm2 start ecosystem.config.js
pm2 reload api # zero-downtime: restarts instances one by one
pm2 save && pm2 startup
See PM2 process manager.
Option 2: the built-in cluster module
// cluster.js
const cluster = require("node:cluster");
const os = require("node:os");
if (cluster.isPrimary) {
const n = Number(process.env.WORKERS) || os.availableParallelism();
for (let i = 0; i < n; i++) cluster.fork();
cluster.on("exit", (worker) => {
console.log(`worker ${worker.process.pid} died, restarting`);
cluster.fork();
});
} else {
require("./server"); // each worker runs the app and shares the listening port
}
PM2 does essentially this, plus monitoring, logs and graceful reloads.
Option 3: multiple containers or processes behind Nginx
Run several single-process instances (Docker replicas, or systemd template units on different ports), and load-balance with Nginx:
upstream api {
least_conn;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
}
That’s useful when you want container-level isolation, or to scale across servers later. See Docker Compose scale and Nginx load balancing.
How many instances?
- Start with one per vCPU. Leave a core for the database, Redis or Nginx if they share the VPS.
- Each instance has its own memory (heap, caches), so RAM sets an upper limit. See how much RAM a VPS needs.
- I/O-bound apps sometimes do fine with fewer instances. Measure with a load test. See load testing with k6.
Things that break in cluster mode
Instances don’t share memory. Anything stored in a process breaks when requests land on different instances:
| Problem | Fix |
|---|---|
| In-memory sessions | Store sessions in Redis |
| In-memory caches | Redis or a shared cache (or accept per-instance caches) |
Scheduled jobs (setInterval, node-cron) run N times |
Run schedulers in one dedicated process, or use a job queue |
| WebSockets / Socket.IO | Sticky sessions + the Socket.IO Redis adapter |
| Rate limiting in memory | Use a Redis-backed limiter |
Socket.IO needs sticky sessions (the same client always reaches the same instance) during the handshake: use @socket.io/sticky with the cluster module, ip_hash in Nginx for separate processes, or a PM2-compatible setup, plus @socket.io/redis-adapter to broadcast across instances.
Zero-downtime deploys
pm2 reload restarts instances one at a time, waiting for each to be ready. Implement graceful shutdown, so in-flight requests finish:
process.on("SIGINT", () => server.close(() => process.exit(0))); // PM2 sends SIGINT
process.on("SIGTERM", () => server.close(() => process.exit(0))); // Docker/systemd send SIGTERM
See zero-downtime Node.js deploys.
Alternatives to clustering
- Worker threads for CPU-heavy tasks inside one process (image processing, parsing).
- Background job queues (BullMQ with Redis) to move heavy work out of the request path. See background jobs and queues.
Quick reference
| Task | Command |
|---|---|
| Cluster with PM2 | pm2 start app.js -i max |
| Reload without downtime | pm2 reload <name> |
| Restart on high memory | max_memory_restart: "400M" |
| Built-in | node:cluster + cluster.fork() |
| Shared state | Redis (sessions, cache, rate limits) |
| WebSockets | Sticky sessions + Redis adapter |
Related: Node.js memory leaks and Node.js environment variables.