Every deploy of a busy Node.js server does the same quiet thing: it errors on a handful of real user requests. Not because the code is wrong. Because the process dies while requests are still in flight, and nobody set up a shutdown.
If you have ever seen a spike of 502s right after a deploy, this is why.
Step 1: Understand what happens on deploy
On Vercel, Render, Railway, Kubernetes, or a Docker host, stopping a process looks like this:
- The platform sends
SIGTERMto your process. - It waits a grace period. Often 10 to 30 seconds (Kubernetes default: 30s via
terminationGracePeriodSeconds). - It sends
SIGKILL. No handlers run. Everything dies.
A default Node server ignores SIGTERM, gets killed at the deadline, and every request that started in the last seconds of the window fails. The fix is not infrastructure. It is about twenty lines in your server file.
Step 2: Listen for the signal and stop accepting new work
const server = app.listen(PORT);
process.on("SIGTERM", () => {
console.log("SIGTERM received, draining connections");
server.close(() => {
console.log("HTTP closed");
});
});server.close() does two things: it stops accepting new connections, and it waits for in-flight requests on existing connections to finish before invoking the callback.
Behind a load balancer this first part matters more than people expect. Once your server closes its listener, health checks can see the port stop accepting and the platform routes new requests elsewhere. That is your process telling the world "finish with me, do not start with me."
Step 3: Handle keep-alive connections
There is one gotcha: browsers and clients using keep-alive hold connections open, and Node will not consider itself done while they linger. Force idle keep-alive sockets to release during shutdown:
process.on("SIGTERM", () => {
server.close(() => console.log("HTTP closed"));
server.closeIdleConnections();
// Node 18.2+: also force sockets still active past a short window
setTimeout(() => server.closeAllConnections(), 5000);
});closeIdleConnections() releases sockets with no request in flight, which is most of them. The fallback timer handles clients streaming long responses so they are not cut off mid-body.
Step 4: Shut down dependencies in order
Most guides stop at server.close() and miss the part that actually fires errors: your background work. Close dependencies after HTTP draining, not before:
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`${signal}: draining HTTP`);
await new Promise((resolve) => server.close(resolve));
server.closeIdleConnections();
console.log("closing queue consumers");
await worker.close(); // finish or requeue in-flight jobs
console.log("closing database");
await prisma.$disconnect(); // release the pool last
process.exit(0);
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT")); // Ctrl-C in devThe order is the lesson. Database first means drained requests hit a closed pool and fail, which defeats the whole exercise. The shuttingDown guard also makes double SIGTERM (platforms love sending it twice) harmless.
If you run cron-style jobs or interval tasks, clear them at the top of shutdown so nothing new starts while you drain.
Step 5: Add a hard timeout as the last resort
A stuck request (a client uploading slowly, a hung query) can keep your process alive past the platform's grace period. Then SIGKILL fires anyway, and now you died and skipped cleanup. Give draining a deadline:
const HARD_TIMEOUT = Number(process.env.SHUTDOWN_TIMEOUT ?? 20_000);
await Promise.race([
drainEverything(),
new Promise((resolve) => {
setTimeout(() => {
console.error(`Shutdown timed out after ${HARD_TIMEOUT}ms, forcing exit`);
resolve();
}, HARD_TIMEOUT);
}),
]);
process.exit(0);Keep the hard timeout a few seconds under your platform's grace period. If Kubernetes gives you 30 seconds, force exit at 25 so cleanup actually runs instead of being murdered mid-step.
Testing it takes two minutes
Start your server, fire a long request with curl, then kill the process while it runs:
curl -s http://localhost:3000/slow-endpoint &
kill -TERM <pid>Watch the logs: you should see the drain message, the request completing normally, then "HTTP closed" and a clean exit code 0. If curl gets a connection error instead, your ordering is wrong somewhere and you just found it before your users did.
Deploys are not supposed to be an outage. If yours drop requests, this is one of the highest-value twenty lines you will add this year.
Need a second pair of eyes on your Node.js backend before your next scale-up? I build and harden production APIs for a living. Tell me about your stack and what is misbehaving.