DeploymentGENERALCLOUD-SPECIFICRUNTIME-SPECIFIC

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.

What actually happensHow to build it

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.

The question

What must a backend process do between receiving SIGTERM and exiting, so that no request and no job is lost?

The requirement

Deploys happen several times a day and the platform recycles instances on its own schedule. Users must not see errors because of either.

The obvious build

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.

Why it breaks

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.

How it breaks in production
  • 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.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

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.

SIGTERM to exit(0)
  1. 1
    SIGTERM 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).

  2. 2
    Fail readiness

    Readiness probe starts returning failure. Liveness must still pass.

    fails by Failing liveness too, so the platform kills the process mid-drain.

  3. 3
    Drain 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.

  4. 4
    Stop accepting

    Close the listener; pause queue consumers; send Connection: close.

    fails by Closing existing connections too, cutting off in-flight requests.

  5. 5
    Drain in-flight

    Wait for active requests and claimed jobs, up to a deadline.

    fails by Waiting unbounded on a streaming connection until SIGKILL.

  6. 6
    Release work

    Finish or explicitly return queue messages; cancel outbound calls.

    fails by Letting leases expire, causing redelivery while the handler still runs.

  7. 7
    Close 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.

  8. 8
    exit(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

RUNTIME-SPECIFICNode/Express shown. Go's 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.

Ordered shutdown with a hard deadline
1let ready = true
2let inFlight = 0
3
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 connection
10 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 time
17const DRAIN_DEADLINE_MS = 20_000 // < platform grace period
18
19let shuttingDown = false
20async function shutdown(signal: string) {
21 if (shuttingDown) return // SIGTERM can arrive twice
22 shuttingDown = true
23 log.info({ signal, inFlight }, 'shutdown: begin')
24
25 // hard stop: never let the platform be the one to end this
26 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 work
33 ready = false // 2. readiness starts failing
34 await sleep(DRAIN_DELAY_MS) // 3. let the LB notice
35
36 await Promise.all([
37 closeListener(server), // 4. no new connections
38 consumer.pause(), // no new queue messages
39 ])
40
41 await waitFor(() => inFlight === 0, DRAIN_DEADLINE_MS - DRAIN_DELAY_MS)
42 await consumer.releaseInFlight() // 5. nack, do not let leases expire
43
44 await httpClient.close() // 6. dependency order
45 await db.end()
46 await telemetry.shutdown() // last: the drain itself is observable
47
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.

Shutdown failure signatures
TriggerSymptomCauseResponse
Deploy startsBurst of 502 / connection reset, ends when rollout endsNo drain delay: LB still routing to an instance that closed its listenerFail readiness, then sleep past the deregistration interval before closing the listener.
Deploy startsClient errors only on reused connectionsKeep-alive connection reused into a draining processSend Connection: close on responses once draining.
DeployRollout takes the full grace period per instanceDrain waiting on a long-lived or streaming connectionTrack long-lived connections separately; close them explicitly with a shorter deadline.
DeployExit code 137 / OOMKilled-style kill on every instanceSIGKILL after grace period expired — drain never completedAdd the hard timer; make the drain deadline shorter than the grace period.
Scale-downJobs re-run hours later, or vanishWorker killed holding an unacknowledged messagePause fetch, finish or explicitly release in-flight messages, and make handlers idempotent (Job Idempotency).
RolloutDatabase shows a spike of aborted connectionsPool not closed; server waits out its own idle timeoutClose the pool after in-flight requests complete, before exit.
Any restartNothing in logs about the shutdown at allTelemetry exporter closed first, or the process is not PID 1Close 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: close on 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

Failure modes
  • 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.
What can race
  • 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: close on 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).
Security
  • 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.
Misreads
  • "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

How you see it in production
  • 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/OOMKilled exit 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).
What changes at 10x and 100x
  • 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).
What this costs
  • 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's http.Server.Shutdown closes 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.

OS & Networkingsignalskeep-alive