Graceful Shutdown
SIGTERM arrives, and the process has one job: stop taking new work, finish or cancel what it holds, release everything, and exit before it is killed.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
What must a backend process do between receiving SIGTERM and exiting, so that no request and no job is lost?
Deploys happen several times a day and the platform recycles instances on its own schedule. Users must not see errors because of either.
The platform stops the old instance and starts a new one. Requests in flight will finish — the process does not exit instantly, and the load balancer will notice.
Every deploy produces a burst of 502s and connection resets proportional to in-flight traffic. It is small enough to blame on the network and it happens every single time.
- Every deploy produces a burst of 502s and connection resets proportional to in-flight traffic. It is small enough to blame on the network and it happens every single time.
- A worker is killed holding a job it has already claimed but not acknowledged. Depending on the queue, that job is redelivered later, or lost entirely (Job Queues).
- A handler is killed between the external payment call and the database write, leaving the two permanently disagreeing (The Dual Write Problem).
- Database connections are dropped rather than closed, so the server keeps them in an open state until its own idle timeout, and the connection budget briefly halves during a rollout (Connection Pools).
- The instance stops passing readiness after it stops serving, so for a few seconds the load balancer is still routing requests to a socket nobody is accepting on.
What is actually happening
- The platform sends SIGTERM and starts a timer — the termination grace period. When it expires it sends SIGKILL, which cannot be caught, blocked or delayed. Everything graceful must happen inside that window.
- Removing the instance from the load balancer is not synchronous with SIGTERM. In most systems both happen in response to the same event, in parallel, and requests routed just before removal can arrive just after. Shutdown must therefore keep serving for a short while after it has begun.
- Closing the listening socket stops new connections being accepted; it does not close existing keep-alive connections, which may send another request on the same connection (Keep-Alive and Connection Reuse).
- In-flight work has two categories: requests, which have a client waiting and should be finished, and background work, which has no client and should be either finished quickly or abandoned safely.
- A queue consumer's obligation is different from a server's. It should stop fetching new messages and then decide, per in-flight message, whether to finish it or return it to the queue for redelivery — which requires the handler to be idempotent (Job Idempotency).
- Exiting with a nonzero status or being SIGKILLed is recorded by the platform as a crash, which affects restart backoff and rollout health decisions. A clean shutdown exits 0.
The order is the whole lesson
Shutdown fails in a specific way: the steps are all present but in the wrong order, or one step — the drain delay — is missing entirely. The delay looks like a hack (why would a healthy process deliberately keep serving after being told to stop?) and it is the only thing that closes the window between "the platform decided to remove you" and "the load balancer stopped routing to you".
Read the pipeline as a contract with the outside world. Until readiness has been failing long enough for the load balancer to notice, you are still a valid destination and must behave like one.
- 1SIGTERM received
Set a shutting-down flag; stop the scheduler; log the start.
fails by Not being PID 1, so the signal never arrives (Containerizing a Backend).
- 2Fail readiness
Readiness probe starts returning failure. Liveness must still pass.
fails by Failing liveness too, so the platform kills the process mid-drain.
- 3Drain delay
Keep serving normally for a few seconds while the LB removes you.
fails by Skipping it — the cause of nearly every deploy-time 502.
- 4Stop accepting
Close the listener; pause queue consumers; send
Connection: close.fails by Closing existing connections too, cutting off in-flight requests.
- 5Drain in-flight
Wait for active requests and claimed jobs, up to a deadline.
fails by Waiting unbounded on a streaming connection until SIGKILL.
- 6Release work
Finish or explicitly return queue messages; cancel outbound calls.
fails by Letting leases expire, causing redelivery while the handler still runs.
- 7Close resources
Outbound clients, then DB pool, then telemetry exporter last.
fails by Closing the pool while a handler is mid-query; closing telemetry first and losing the shutdown trace.
- 8exit(0)
Clean exit inside the grace period.
fails by Nonzero exit recorded as a crash, affecting restart backoff and rollout health.
Liveness and readiness diverge here on purpose: readiness says "do not send me traffic", liveness says "I am not wedged, do not kill me". Conflating them turns a graceful drain into a hard restart.
What it looks like in code
srv.Shutdown(ctx) implements steps 4-5 with a context deadline; Python under Gunicorn requires the master to forward the signal and each worker to run this sequence itself. The ordering is identical in all three.The shape below is framework-free on purpose: an ordered sequence guarded by a hard timer. The hard timer matters as much as the sequence — without it, one stuck handler makes every deploy take the full grace period and end in SIGKILL anyway.
Note that the drain deadline, the drain delay and the platform grace period are three numbers that must be consistent. The application can only enforce two of them; the third lives in the deployment manifest, and keeping them in sync is a real operational obligation.
1let ready = true2let inFlight = 03 4app.get('/readyz', (_req, res) =>5 ready ? res.status(200).send('ok') : res.status(503).send('draining'))6 7app.use((req, res, next) => {8 inFlight++9 // during drain, tell keep-alive clients not to reuse this connection10 if (!ready) res.set('Connection', 'close')11 res.on('finish', () => inFlight--)12 res.on('close', () => { if (!res.writableEnded) inFlight-- })13 next()14})15 16const DRAIN_DELAY_MS = 5_000 // < LB deregistration time17const DRAIN_DEADLINE_MS = 20_000 // < platform grace period18 19let shuttingDown = false20async function shutdown(signal: string) {21 if (shuttingDown) return // SIGTERM can arrive twice22 shuttingDown = true23 log.info({ signal, inFlight }, 'shutdown: begin')24 25 // hard stop: never let the platform be the one to end this26 const hardKill = setTimeout(() => {27 log.error({ inFlight }, 'shutdown: deadline exceeded, forcing exit')28 process.exit(1)29 }, DRAIN_DEADLINE_MS)30 hardKill.unref()31 32 scheduler.stop() // 1. no new timed work33 ready = false // 2. readiness starts failing34 await sleep(DRAIN_DELAY_MS) // 3. let the LB notice35 36 await Promise.all([37 closeListener(server), // 4. no new connections38 consumer.pause(), // no new queue messages39 ])40 41 await waitFor(() => inFlight === 0, DRAIN_DEADLINE_MS - DRAIN_DELAY_MS)42 await consumer.releaseInFlight() // 5. nack, do not let leases expire43 44 await httpClient.close() // 6. dependency order45 await db.end()46 await telemetry.shutdown() // last: the drain itself is observable47 48 clearTimeout(hardKill)49 log.info('shutdown: clean')50 process.exit(0)51}52 53process.on('SIGTERM', () => void shutdown('SIGTERM'))54process.on('SIGINT', () => void shutdown('SIGINT'))Three things are easy to miss. The Connection: close header during drain, without which a keep-alive client sends its next request into a closing process. The idempotence guard, because SIGTERM genuinely arrives twice. And telemetry.shutdown() last, so the logs and spans describing the drain actually leave the process.
What goes wrong, and what it looks like
Shutdown defects share a signature: they correlate perfectly with deploys and scale events, and they leave nothing in the application logs — because the interesting moment is the one where the application stopped being able to log.
Work the table by symptom. The distinction between "errors at the start of a rollout" and "errors at the end of it" is usually enough to identify which step is missing.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Deploy starts | Burst of 502 / connection reset, ends when rollout ends | No drain delay: LB still routing to an instance that closed its listener | Fail readiness, then sleep past the deregistration interval before closing the listener. |
| Deploy starts | Client errors only on reused connections | Keep-alive connection reused into a draining process | Send Connection: close on responses once draining. |
| Deploy | Rollout takes the full grace period per instance | Drain waiting on a long-lived or streaming connection | Track long-lived connections separately; close them explicitly with a shorter deadline. |
| Deploy | Exit code 137 / OOMKilled-style kill on every instance | SIGKILL after grace period expired — drain never completed | Add the hard timer; make the drain deadline shorter than the grace period. |
| Scale-down | Jobs re-run hours later, or vanish | Worker killed holding an unacknowledged message | Pause fetch, finish or explicitly release in-flight messages, and make handlers idempotent (Job Idempotency). |
| Rollout | Database shows a spike of aborted connections | Pool not closed; server waits out its own idle timeout | Close the pool after in-flight requests complete, before exit. |
| Any restart | Nothing in logs about the shutdown at all | Telemetry exporter closed first, or the process is not PID 1 | Close telemetry last; use exec form so signals reach the process. |
How to build it
Most important first.
- Flip readiness to failing first, then wait a fixed drain delay before you stop accepting. This is the step almost everyone omits, and it is the one that removes the deploy-time errors, because it gives the load balancer time to stop sending you traffic (Health Checks: Startup, Readiness, Liveness).
- Stop accepting new work: close the HTTP listener, pause the queue consumer, stop the scheduler. Existing connections stay open.
- Finish in-flight requests with a bounded deadline that is comfortably shorter than the grace period. Send
Connection: closeon responses during drain so keep-alive clients do not send another request. - Cancel or complete background work: propagate cancellation to anything the process started, and for queue messages either finish or release them explicitly rather than letting the lease expire (Cancellation Propagation in Concurrency).
- Close resources in dependency order: request handling, then outbound clients, then the database pool, then the telemetry exporter — the exporter last, so the shutdown itself is observable.
- Exit 0 when done, and install a hard timer that force-exits if drain overruns, so a stuck handler cannot make every deploy wait for SIGKILL.
- Make the grace period longer than your longest normal request plus the drain delay. If your p99 is 8 s and the grace period is 10 s, your deploys are already truncating requests.
What can go wrong
- Drain waits forever on a long-poll or streaming connection, so the process is SIGKILLed after doing everything else correctly. Track long-lived connections separately and close them deliberately.
- Readiness is flipped but the process exits immediately, which is the same as not draining at all.
- The shutdown handler itself throws — typically on closing a pool that was never initialised — and the process dies before finishing the drain.
- Two SIGTERMs (an impatient operator, or a platform retry) re-enter the handler and it closes an already-closed pool. Make shutdown idempotent.
- Grace period shorter than the drain deadline, so the careful design never gets to run. These two numbers live in different files owned by different people and drift apart.
- A request routed by the load balancer microseconds before the instance was removed arrives after SIGTERM. Without a drain delay, it hits a closed listener and the client sees a connection reset.
- A keep-alive connection can carry a new request after the listener is closed. Without
Connection: closeon drain responses, the client reuses the connection into a dying process. - A scheduled job can fire during drain and start work that will not finish. The scheduler must be stopped before the drain, not with it (Scheduled Jobs).
- A queue consumer's message lease can expire mid-shutdown, causing redelivery while the original handler is still running — two executions of the same job, concurrently (Duplicate Detection).
- Do not skip audit and security logging during shutdown. An attacker who can cause a restart should not be able to erase the record of what happened in the final seconds (Audit Logs for Privileged Actions in Security Engineering).
- Flush anything that grants or revokes access before exit — a revocation that was buffered in memory and lost on SIGKILL leaves a session valid that should not be.
- A shutdown endpoint reachable over HTTP is a denial-of-service primitive. Termination should come from the platform's signal, not from a route.
- "SIGTERM means the load balancer has already stopped sending traffic." It usually means the opposite is still settling. Requests arriving after SIGTERM are normal and must be served.
- "We handle SIGTERM, so we drain." Handling the signal and immediately closing the server is a fast, orderly way to drop exactly the same requests.
- "The framework does this for me." Many do close the HTTP server on a signal. Almost none flip readiness first, drain the queue consumer, or close your pool in the right order — and none of them know your grace period.
- "Graceful shutdown is a deploy concern." It is a lifecycle concern. Scale-down, node drains, spot reclamation and OOM restarts all use the same path, and they are more frequent than deploys.
Operating it
- Log a line at each shutdown phase with a duration: readiness flipped, drain started, in-flight count, pool closed, exit. When a deploy is slow, this is the only trace of why.
- Emit a metric for in-flight requests at the moment drain begins, and for requests that were still in flight when the deadline hit. The second number should be zero and is not.
- Compare
SIGKILL/OOMKilledexit reasons against clean exits across a rollout; any non-clean exit during a normal deploy is a defect, not noise. - Watch the 5xx and connection-reset rate aligned to deploy markers. If errors form a spike at every deploy, drain is the first suspect ("What Changed?" — Deploy Markers and the Invisible Deploys in Observability).
- At one instance a deploy is one blip. At 50 instances a rolling deploy performs the same shutdown 50 times, so a 0.3% error rate per shutdown is a visible incident.
- At high request rates the number of in-flight requests at SIGTERM is larger, so the drain deadline matters more and the LB removal race widens.
- Autoscaling multiplies the frequency: scale-down terminates instances all day, not only at deploy time, so a service that only survives deploys still bleeds errors (Autoscaling a Backend).
- The drain delay adds a fixed few seconds to every instance shutdown, which lengthens every rollout. That is the price of not dropping requests, and it is worth it.
- A bounded drain deadline means some genuinely in-flight requests are cut off. Unbounded drain means a stuck handler blocks the deploy until SIGKILL. Pick the bound deliberately.
- Returning in-flight queue messages for redelivery requires idempotent handlers. Graceful shutdown for workers is only correct if that work has already been done.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe sequence — fail readiness, delay, stop accepting, drain, close, exit — holds for every platform that terminates processes, which is all of them.
- CLOUD-SPECIFICThe grace period default and whether load-balancer deregistration is completed before SIGTERM differ by platform. Kubernetes sends SIGTERM and removes the endpoint concurrently; some managed load balancers deregister first but only complete after a configurable delay. Read your platform's ordering rather than assuming it.
- RUNTIME-SPECIFICNode's
server.close()waits for idle keep-alive sockets too unless they are closed explicitly; Go'shttp.Server.Shutdowncloses idle connections for you; a pre-fork Python server must forward the signal to workers and each worker drains independently.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.