Your app gets stopped far more often than it crashes. Every deploy, every scale-down, every node upgrade, every spot-instance reclamation ends the same way: something sends your process SIGTERM and starts a countdown. Handle it well and nobody notices. Handle it badly and you get the classic symptom — a small burst of 502s that "only happens during deploys."
The fix isn't complicated, but it has four distinct parts, and most guides only cover one of them. Here's the whole thing.
What actually happens when your process is told to stop
On Kubernetes (which is what's underneath most modern platforms), termination looks like this:
- 1The pod is marked
Terminatingand removed from the Service's endpoints. - 2
SIGTERMis sent to your container's PID 1. - 3A grace period runs —
terminationGracePeriodSeconds, 30 seconds by default. - 4If you're still alive after that,
SIGKILL. No cleanup, no appeal.
The detail that bites people: steps 1 and 2 happen in parallel, not in order. Endpoint removal propagates asynchronously across nodes and load balancers, so you can — and will — receive new requests *after* SIGTERM arrives. Plain docker stop is the same shape with a shorter fuse: SIGTERM, 10 seconds, SIGKILL.
Everything below is about using that window correctly.
Step 0: make sure the signal reaches your process
This is the most common failure, and it happens before you write a single line of handler code. If your process isn't PID 1 in the container — or is wrapped by a shell — SIGTERM never reaches it, and you get SIGKILLed after the full grace period every time.
# Bad: shell form. Docker runs /bin/sh -c "node server.js";
# the shell gets SIGTERM and does not forward it.
CMD node server.js
# Good: exec form. node is PID 1 and receives the signal.
CMD ["node", "server.js"]Also avoid CMD ["npm", "start"]. npm spawns node as a child process, and signal forwarding through npm has historically been unreliable. Run the runtime directly.
Quick test: docker stop your container. If it exits in under a second, your handler ran. If it takes exactly 10 seconds, your process never saw the signal.
Step 1: stop accepting new work, finish in-flight work
Node's server.close() does two things: stops accepting new connections, and waits for existing requests to complete before firing its callback. That's most of what you want — with one trap. Keep-alive connections that are open but *idle* count as "existing," and close() will wait on them indefinitely. Node 18.2+ has the fix built in:
const server = app.listen(port);
let shuttingDown = false;
process.on('SIGTERM', () => {
shuttingDown = true;
// Stop accepting connections; wait for in-flight requests.
server.close(() => process.exit(0));
// Idle keep-alive sockets would otherwise block close() forever.
server.closeIdleConnections();
// Hard deadline, comfortably under the platform's kill timeout.
setTimeout(() => {
server.closeAllConnections();
process.exit(0);
}, 20_000).unref();
});The .unref() matters: without it, the timer itself keeps the event loop alive even after everything else has drained.
Step 2: lose the race with the load balancer gracefully
Because endpoint removal is async, slamming the door the instant SIGTERM arrives means refusing requests that were already routed to you. The counterintuitive move is to keep serving for a few seconds *after* you've been told to die.
Two pieces. First, a readiness endpoint that flips as soon as shutdown starts, so the platform stops sending you traffic as fast as it can observe:
app.get('/healthz', (_req, res) => {
if (shuttingDown) return res.status(503).send('shutting down');
res.status(200).send('ok');
});Second, a short delay before actually closing:
process.on('SIGTERM', async () => {
shuttingDown = true; // readiness now fails
await new Promise((r) => setTimeout(r, 5000)); // let routing catch up
server.close(() => process.exit(0));
server.closeIdleConnections();
});If you control the Kubernetes manifest, a preStop hook with sleep 5 achieves the same thing without app code. Either way, the delay plus your drain time must fit inside the grace period.
Step 3: drain everything that isn't HTTP
HTTP is the visible half. The other half is everything your process holds open:
- Database pools —
pool.end()inpg,$disconnect()in Prisma. Do this *after* in-flight requests finish, or you'll yank connections out from under your own handlers. - Queue consumers — stop pulling new messages first, then finish and ack what you've already taken. A message killed mid-processing gets redelivered; make sure that's safe (idempotency) or drained (graceful stop).
- Timers and background loops — clear them, or
unref()them at creation.
The ordering rule: stop intake → finish work → close outbound connections. In code:
server.close(async () => {
await queueConsumer.stop(); // finish + ack in-flight messages
await pool.end(); // now safe to drop DB connections
process.exit(0);
});Budget your timeouts as a chain
Every deadline must be strictly smaller than the one above it:
platform kill timeout (30s)
> your force-exit timer (20s)
> slowest request or job you'll wait for (15s)If you have legitimate 60-second work — big uploads, long-running jobs — raise the grace period rather than hoping, or checkpoint the work so a redelivery resumes instead of restarting.
The same thing in Go
Go's standard library has this built in, which is one reason Go services tend to behave well here:
srv := &http.Server{Addr: ":8080", Handler: mux}
go srv.ListenAndServe()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, os.Interrupt)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
srv.Shutdown(ctx) // stops listeners, waits for in-flight requestsShutdown handles the close-listeners-then-drain dance; you supply the deadline via context.
How to actually test it
Don't wait for a deploy to find out. Locally:
# Terminal 1: start the app, hit it with a deliberately slow request
curl localhost:3000/slow & # an endpoint that sleeps 10s
# Terminal 2: send the real signal
kill -TERM $(pgrep -f "node dist/server.js")The slow request should complete with a 200; new requests should be refused; the process should exit on its own. Then repeat with docker stop to verify the signal actually crosses the container boundary — that's where Step 0 failures show up.
Why this matters more now than it used to
On a VM that ran for months, graceful shutdown was a deploy-day nicety. On modern platforms it's a constant. PandaStack's free tier, for instance, scales idle apps to zero via KEDA and runs them on preemptible nodes — so your process gets stopped routinely as part of normal operation, not exceptionally. Every deploy and rollback replaces the running container too. The platforms are built so that none of this drops traffic, but only if your process holds up its end: catch SIGTERM, drain, exit.
Get the four steps in and shutdowns become invisible — which is the whole point. If you want to see it in action behind a git-push deploy flow, it's easy to try on https://pandastack.io.