Blog

Killing Node Servers Politely: Graceful Shutdown and Zero-Downtime Deploys

How to make Node processes drain in-flight requests, close connections cleanly, and roll out new versions without dropping a single request or corrupting work.

June 20, 20266 min readMuhammad Shehzaib
NODEJSDEVOPSDOCKERDEPLOY

Every deploy stops one version of your app and starts another. If you do that carelessly, the old process is killed mid-request: users see connection resets, half-finished database writes leave inconsistent state, and jobs vanish. Graceful shutdown is the discipline of stopping a process the right way. When the platform sends a termination signal, the app stops accepting new work, finishes the requests already in flight, closes its database and message connections, and only then exits. Pair that with a deployment strategy that brings up new instances before retiring old ones, and you get rollouts where no request is ever dropped. This matters more in containers and Kubernetes, where pods are restarted constantly during scaling, deploys, and node maintenance. Getting shutdown right is not glamorous, but it is the difference between deploys nobody notices and deploys that page you at midnight. This post covers signals, draining, and zero-downtime rollout patterns.

Why Abrupt Exits Hurt

When a process is killed without warning, every connection it held is severed instantly. An HTTP request that was halfway through generating a response returns a connection reset to the client, which the user experiences as a random failure. Worse are stateful operations: a request that had written to one table but not yet committed a related update can leave your database inconsistent. A background job pulled from a queue but not yet acknowledged may be lost or, depending on the queue, redelivered and run twice. Open database and Redis connections are dropped rather than returned to their pools, and the server on the other end may take time to notice. In a system with Kafka consumers, an abrupt exit can leave offsets uncommitted, causing reprocessing. None of this is hypothetical; it happens on every deploy unless you handle it. The cost of skipping graceful shutdown is paid in flaky failures that are maddening to reproduce.

Signals And Their Meaning

Process managers communicate with your app through Unix signals. The important one is SIGTERM, the polite request to terminate that Docker, Kubernetes, and most orchestrators send first when stopping a container. Your app should listen for it and begin graceful shutdown. SIGINT is what you get when you press control C in a terminal, useful for local development. SIGKILL cannot be caught or handled; it forcibly kills the process and is what the platform sends if you fail to exit within a grace period, typically around thirty seconds in Kubernetes. The contract is simple: you receive SIGTERM, you have a limited window to clean up, and if you overrun, you get SIGKILL and lose whatever was unfinished. In Node, you register a handler with process.on for SIGTERM. A subtle gotcha is that signals behave differently when your app is not process one inside a container, which the next section addresses.

Draining In Flight Requests

The heart of graceful shutdown is draining. On receiving SIGTERM, first tell the HTTP server to stop accepting new connections by calling its close method, which keeps existing connections alive until their current requests finish but rejects new ones. Then wait for in-flight requests to complete before exiting. A plain server.close resolves only once all connections are idle, but keep-alive connections can linger, so production setups track active requests or use a helper library that forces idle keep-alive sockets closed while letting active requests finish. You should also flip a readiness flag so health checks report not ready, which signals the load balancer to stop routing new traffic to this instance even before connections close. Always set a hard timeout: if requests do not finish within, say, ten seconds, force the exit rather than hanging until SIGKILL. Draining turns a violent stop into an orderly wind-down where every accepted request still gets its answer.

Closing Resources In Order

After the HTTP server drains, close your other resources deliberately and in a sensible order. Stop pulling new work first: pause message queue consumers and Kafka subscriptions so no new jobs begin during shutdown. Let any in-progress jobs finish and acknowledge them so they are not redelivered. Then close database connection pools, which flushes and returns connections cleanly rather than dropping them. Close your Redis client and any other external clients. Flush buffered logs and metrics so you do not lose the final observations from this instance. Order matters because closing the database before in-flight requests finish would cause those requests to fail at the last moment. Wrap each close in error handling so one stubborn resource does not prevent the others from closing. The goal is that by the time you call process exit, there is no unfinished work, no open socket leaking, and no buffered telemetry waiting to be sent.

The PID One Problem

Inside a container, the first process started becomes process one, which has special responsibilities in Unix: it must reap zombie child processes and it does not get default signal handlers. If your Node app runs as process one and you start it through a shell, for example a Dockerfile CMD written as a shell string, the shell may become process one and fail to forward SIGTERM to Node, so your graceful handler never runs and the container is eventually SIGKILLed. The fix is to ensure Node receives the signal directly. Use the exec form of CMD so Node is the process, or run a tiny init like tini, which Docker provides through a run flag, to handle signal forwarding and zombie reaping. Many base images and orchestrators offer an init option for exactly this. If your graceful shutdown code is correct but never seems to fire in production, the PID one signal forwarding problem is the first thing to check.

Health Checks And Readiness

Zero-downtime deploys depend on the orchestrator knowing when an instance is ready and when it is not. Expose two distinct endpoints. A liveness check answers whether the process is alive at all; if it fails repeatedly, the platform restarts the container. A readiness check answers whether this instance should receive traffic right now; it considers whether dependencies like the database are reachable and whether the app is shutting down. During startup, readiness stays false until connections are established, so traffic does not hit an instance that cannot serve it. During shutdown, you flip readiness to false the moment SIGTERM arrives, which prompts the load balancer to drain this instance before connections close. Keep liveness simple and cheap so a slow dependency does not trigger needless restarts, and keep readiness honest about real dependencies. Misconfiguring these is a top cause of deploys that drop traffic despite otherwise correct shutdown code.

Rolling And Blue Green

Zero-downtime rollout means new instances serve traffic before old ones stop. A rolling deploy replaces instances gradually: bring up one new version, wait for its readiness check to pass, route traffic to it, then terminate one old instance, and repeat until all are replaced. At every moment there is enough healthy capacity to serve requests. Kubernetes does this natively with a rolling update strategy, controlling how many instances can be unavailable or surged at once. Blue green deployment runs two full environments and switches the load balancer from the old to the new in one step, with instant rollback by switching back. Canary deployment sends a small percentage of traffic to the new version first to catch problems before full rollout. All of these depend on graceful shutdown underneath: rolling out new instances is pointless if retiring the old ones drops their in-flight requests. The strategy and the shutdown discipline are two halves of the same guarantee.

Migrations And Compatibility

The hardest part of zero-downtime deploys is the database, because during a rolling deploy old and new code run simultaneously against the same schema. A migration that renames or drops a column breaks the old code still serving traffic, causing errors until it is fully retired. The solution is backward-compatible, expand and contract migrations. To rename a column, first add the new column and write to both, deploy code that reads the new and falls back to the old, backfill data, then in a later deploy stop using the old column, and only after that drop it. Each step is safe with both versions running. Never combine a breaking schema change with the deploy that depends on it. The same discipline applies to message and event formats: consumers must tolerate both old and new shapes during the overlap. Treat every schema and contract change as something both versions must survive, and your deploys stay invisible to users.