Zero-Downtime Node Deploys with PM2 Cluster Mode and Graceful Reload
PM2 cluster mode uses all your CPU cores and `pm2 reload` deploys without dropping requests — but only if your app honors the shutdown contract. Here's the config, the code, and how to verify it.
25 Mar 2026, 16:27 UTC

A single Node.js process uses one CPU core. If your API server runs on an eight-core box, seven of those cores are idle while one process juggles every request. The usual fix — put a load balancer in front of multiple app servers — is overkill when the bottleneck is just one machine. PM2's cluster mode solves this in about five lines of configuration, and its reload command lets you deploy new code without dropping a single in-flight request. But the zero-downtime part only works if your application holds up its end of a shutdown contract. This post covers both halves: the configuration, and the code that makes reload safe.
Cluster mode: many workers, one port
Node's built-in cluster module lets multiple worker processes share a single listening socket. PM2 wraps this so you don't write the plumbing yourself. Instead of configuring PM2 with ad-hoc CLI flags, put everything in an ecosystem file — it becomes versioned, reviewable operational config:
// ecosystem.config.js
module.exports = {
apps: [{
name: "api",
script: "./server.js",
exec_mode: "cluster",
instances: 4, // or "max" for one per CPU core
max_memory_restart: "512M",
kill_timeout: 8000, // ms PM2 waits before SIGKILL
wait_ready: true,
listen_timeout: 10000
}]
};Start it with pm2 start ecosystem.config.js (run as the deploy user, in the app directory). Check the result with pm2 list — you should see four online processes — and pm2 describe api for restart counts and memory per worker.
A note on instances: "max" is tempting, but every worker opens its own database connections. Four workers with a pool of 10 each means 40 connections to Postgres. If your database caps at 100 and you also run a reporting tool, an explicit count sized against downstream limits is safer than core count.
Why reload, not restart
pm2 restart api kills all workers, then starts new ones. Any request in flight dies, and there's a window where nothing is listening. pm2 reload api instead replaces workers one at a time: it starts a new worker, waits for it to signal readiness, then tells an old worker to shut down. At every moment, at least one healthy worker is accepting connections, so clients never see a refused connection.
The shutdown contract your app must honor
Reload is only graceful if the old worker exits cleanly. PM2 sends SIGINT (in cluster mode) to the worker and waits up to kill_timeout milliseconds before force-killing it. Your app must, in that window: stop accepting new connections, let in-flight requests finish, close database pools, and exit. Here's the pattern with Express:
const server = app.listen(3000, () => {
// Tell PM2 this worker is ready (required by wait_ready)
if (process.send) process.send("ready");
});
function shutdown(signal) {
console.log(`${signal} received, draining...`);
server.close(() => {
// server.close waits for in-flight requests to finish
db.pool.end().then(() => process.exit(0));
});
// Backstop: exit even if something hangs
setTimeout(() => process.exit(1), 7000).unref();
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));Three details matter. First, server.close() stops accepting new connections but waits for existing ones — that's the graceful part. Second, the backstop timer must be shorter than kill_timeout, or PM2's SIGKILL wins and in-flight requests die anyway. Third, work hidden in detached setTimeout callbacks or fire-and-forget promises is invisible to server.close(); if you have background work, track it and await it explicitly.
Verifying it actually works
Don't trust the config — test it. Add a slow endpoint (GET /slow that responds after 3 seconds), start the app, then in one terminal loop curl -s -o /dev/null -w "%{http_code}\n" localhost:3000/slow while running pm2 reload api in another. Every response should be a 200 with no connection errors. Then check pm2 logs api for your "draining" messages and confirm in pm2 describe api that restart counts incremented cleanly. If you see ECONNREFUSED, either the new worker isn't signaling ready or the old one is dying before draining.
Where cluster mode is the wrong tool
Cluster mode assumes workers are interchangeable. That breaks down for anything with per-process state: in-memory sessions (a user's next request may land on a different worker), local caches, WebSocket or SSE fan-out (a broadcast only reaches clients connected to that worker), and singleton jobs like cron schedulers, which would run four times. The fixes are architectural, not configurational: move sessions and caches to Redis, route WebSocket fan-out through a pub/sub adapter, and run scheduled jobs as a separate one-instance PM2 app. Also note that signal behavior can differ across PM2 versions and operating systems — Windows in particular is worth testing explicitly rather than assuming Linux semantics.
The takeaway
PM2 cluster mode is the cheapest way to use all your cores for a stateless HTTP service, and pm2 reload gives you zero-downtime deploys — but only because your app cooperates. Write the SIGINT/SIGTERM handler, set kill_timeout longer than your slowest realistic request, size instances against your database pool rather than your CPU count, and prove the whole thing with a curl loop during a reload before you trust it in production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.