Zero-Downtime Deploys with PM2: Why `reload` Beats `restart` (and When It Doesn't)
PM2's reload command replaces cluster workers one at a time so deploys don't drop requests — but only if your app is stateless, sends a ready signal, and handles SIGINT correctly. Here's the setup and how to verify it.
27 Jul 2026, 21:35 UTC

Every time you run pm2 restart myapp in production, there's a window — usually a few hundred milliseconds, sometimes seconds — where your Node.js process is dead and incoming requests get refused. For a low-traffic internal tool, nobody notices. For anything user-facing, that's a deploy-time error spike you don't need to accept. PM2's reload command exists specifically to close that window, and understanding how it works changes how you structure production deploys.
The thesis: reload replaces workers one at a time, restart kills everything at once
A restart stops all processes for an app and starts fresh ones. A reload (in cluster mode) replaces workers sequentially: PM2 starts a new worker, waits for it to signal readiness, and only then tells the old worker to shut down. In-flight requests on the old worker are allowed to drain before it's terminated. The result is that at no point is there zero capacity to serve requests.
This only works in cluster mode, where PM2 forks multiple Node.js worker processes (typically one per CPU core) and load-balances incoming connections across them with a built-in round-robin. If your app runs in fork mode (a single process), reload behaves like a restart — there is no second worker to hold the fort while the first one cycles.
A minimal working setup
Centralize the configuration in an ecosystem.config.js file so the deploy behavior is declarative and reviewable:
// ecosystem.config.js
module.exports = {
apps: [{
name: 'api',
script: './server.js',
instances: 'max', // one worker per CPU core
exec_mode: 'cluster',
kill_timeout: 5000, // ms to wait before force-killing a worker
wait_ready: true, // wait for process.send('ready')
listen_timeout: 10000, // ms to wait for the ready signal
max_memory_restart: '512M'
}]
};Two settings here do the heavy lifting for graceful reloads. wait_ready: true tells PM2 not to consider a new worker "up" until the app explicitly says so. Your app must send that signal after it has finished connecting to databases and binding its port:
// server.js
const server = app.listen(3000, () => {
if (process.send) process.send('ready');
});
process.on('SIGINT', () => {
server.close(() => process.exit(0)); // stop accepting, finish in-flight
});The SIGINT handler matters because pm2 reload signals old workers with SIGINT and waits up to kill_timeout milliseconds before force-killing. Without a handler, the worker is killed mid-request and your "zero-downtime" deploy still drops connections. Note that calling server.close() stops new connections but lets existing ones finish — that's the drain behavior you want.
Deploy then becomes, run on the server as the user that owns the PM2 process:
git pull
npm ci --omit=dev
pm2 reload ecosystem.config.js --only apiVerify it actually worked
Don't trust the exit code. Check three things:
pm2 describe api— confirm the exec mode iscluster, the instance count matches your cores, and the restart count incremented rather than the app going errored.pm2 monitduring the reload — you should see workers cycle one at a time, not all simultaneously.- Watch the logs (
pm2 logs api) for your ready signal and clean shutdown messages from old workers.
A practical soak test: run a loop of HTTP requests against the service during a reload and confirm zero non-2xx responses. If you see connection refused errors, either the app isn't in cluster mode or the ready signal isn't wired up.
The trade-offs you accept
Statelessness is mandatory. Cluster workers share nothing. In-memory sessions, caches, or WebSocket connection maps live in one worker and are invisible to the others — and vanish entirely when that worker is reloaded. Move that state to Redis, a database, or sticky-session-aware infrastructure before adopting cluster mode.
Reloads are slower than restarts. Sequential replacement with drain time means a full reload takes roughly (workers × startup time) plus drain. For an 8-core box with a 2-second boot, budget 20+ seconds. That's fine for deploys, painful in a tight rollback loop.
Graceful shutdown is your responsibility. PM2 provides the signaling and the timeout; the app must actually close its server and exit. Long-lived requests that outlast kill_timeout are still cut off — tune the timeout to your slowest legitimate request.
What to do next
If you're running a single fork-mode process today, the path is: make the app stateless (externalize sessions), add the ready signal and SIGINT handler, switch to exec_mode: 'cluster' with instances: 'max', and replace restart with reload in your deploy script. Then run the request-loop test during a deploy and watch the error rate stay at zero. That one change eliminates the most common source of self-inflicted production errors for small Node.js services.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.