Deployment · intermediate
Graceful-shutdown checklist
A practical graceful-shutdown checklist for Node.js services: how to stop accepting new work, drain in-flight requests, close database and message-broker connections, and exit with a defined code, while verifying each phase with observable evidence. Treats shutdown as a sequence of bounded waits with timeouts rather than a single signal handling step.
The symptoms
- •New HTTP requests are still being accepted after the process received SIGTERM, producing 200/201 responses that the orchestrator considers orphaned.
- •In-flight requests are truncated mid-response, with clients reporting connection resets or partial JSON payloads after a deploy or pod restart.
- •Background jobs, queue consumers, or cron tasks are killed mid-transaction, leaving database rows in a half-updated state or unacknowledged broker messages.
- •Database or message-broker clients log "Connection forcibly closed" or "ECONNRESET" errors against the process during the shutdown window.
- •The process exits with code 0 even though work was abandoned, or fails readiness while the load balancer keeps the endpoint in rotation.
- •Restart loops: orchestrator reports CrashLoopBackOff or repeated "exited too quickly" events because the process did not finish its drain within the terminationGracePeriodSeconds window.
Likely causes
- •No SIGTERM handler is registered; the default action terminates the event loop immediately, dropping sockets and DB pools.
- •A SIGTERM handler exists but does not call server.close(), so the listening socket keeps accepting new connections until the process exits.
- •Long-running requests, uploads, or WebSocket connections exceed the configured shutdown timeout, so the drain phase is cut short.
- •Database, cache, or queue clients are closed before in-flight work finishes, producing ECONNRESET against still-open transactions.
- •Health/readiness checks continue to return "ready" during shutdown, so the load balancer keeps routing new traffic to the draining instance.
- •Worker loops (setInterval, setImmediate, queue consumers) are not stopped, so the event loop stays active and process.exit fires despite the drain.
- •Kubernetes sends SIGKILL after terminationGracePeriodSeconds, terminating the process before the configured drain completes.
First ten minutes
- 01Confirm the symptom window: record the timestamp of the deploy, the SIGTERM delivery time, and the process exit code from the orchestrator's lastState.terminated.exitCode field.
- 02Read the Node.js errors reference at https://nodejs.org/api/errors.html to confirm the exact error class names (such as ERR_SERVER_NOT_RUNNING) that should appear if a shutdown handler is missing or misordered.
- 03Search the application logs for the SIGTERM receipt line and the first "close" or "listening" event after it; the gap between them is the drain window.
- 04Inventory the listening sockets (HTTP, HTTPS, gRPC, raw TCP) and the external clients (DB pool, Redis, RabbitMQ, Kafka) that must be closed in order; mark each as "stop-accept" or "drain-then-close".
- 05Check the orchestrator's grace period: terminationGracePeriodSeconds in Kubernetes, StopTimeout in systemd, or the equivalent on the platform, and compare it to the longest expected request duration.
- 06Capture a list of background workers (setInterval, queue subscribers, scheduled jobs) and identify which ones must be stopped before the event loop is allowed to exit.
Evidence to collect
- •Process exit code and termination reason from the orchestrator (lastState.terminated.reason, exitCode, finishedAt).
- •Server lifecycle log lines: "received SIGTERM", "server closing", "server closed", and the elapsed time between them.
- •Per-request log lines whose timestamps span the SIGTERM receipt, to identify truncated or abandoned requests.
- •Health-check responses recorded during the shutdown window, to confirm whether readiness flipped to "not ready" before the socket closed.
- •DB and broker client close events: pool.drain, pool.end, channel.close, consumer.cancel, with their success or error status.
- •Worker stop events: interval cleared, consumer cancelled, scheduled task flushed, with a timestamp for each.
Where to look
- •The Node.js process entry point and any bootstrap module that registers signal handlers or starts the HTTP server.
- •The shutdown module: a function wired to SIGTERM, SIGINT, message events, or process.on('beforeExit').
- •The health-check handler and the readiness/liveness probe configuration on the orchestrator.
- •The reverse proxy or load balancer routing rules, specifically the time it takes to remove a marked-not-ready instance from rotation.
- •Database, cache, and message-broker client initialisation, particularly the pool or channel objects and their close/drain methods.
- •Background worker registration sites: setInterval, setTimeout recurring tasks, queue consumers, cron schedulers, and worker_threads.
Diagnostic steps
- 01Verify a SIGTERM handler exists: search the codebase for process.on('SIGTERM', or signal handlers on a graceful library; absence means the default abort behavior is in effect.
- 02Inside the handler, confirm server.close() is called for every listening HTTP, HTTPS, or gRPC server; a missing call leaves the socket accepting new connections.
- 03Confirm the handler awaits the close callback or its Promise before closing dependent clients, so in-flight requests still have a live DB pool.
- 04Check the readiness endpoint: the SIGTERM handler should set a "shutting down" flag that the probe reads, so the load balancer stops sending new traffic immediately.
- 05Verify the drain has a hard timeout (typically 20-30 seconds) that calls process.exit(1) if the close does not complete, to prevent SIGKILL from the orchestrator.
- 06Confirm workers and interval timers are stopped (clearInterval, consumer.cancel, scheduler.stop) before the exit, so the event loop can drain naturally.
- 07Read the Node.js errors reference to map any observed error class to a remediation: ErrnoException codes such as ECONNRESET, and SystemError markers indicate premature socket closure.
Common mistakes
- •Calling process.exit(0) immediately after registering the SIGTERM handler, which abandons every in-flight request.
- •Closing the DB pool before the HTTP server has finished serving the last request, producing ECONNRESET on the response.
- •Returning success from the readiness probe until the process actually exits, so the load balancer continues to send new traffic.
- •Setting terminationGracePeriodSeconds too low for the slowest legitimate request, so the orchestrator SIGKILLs before the drain completes.
- •Forgetting that setInterval keeps the event loop alive; the process will not exit naturally even after server.close() returns.
- •Listening only for SIGTERM and missing SIGINT, SIGHUP, or container-specific signals, so a different signal bypasses the drain entirely.
- •Closing the server socket without draining WebSocket connections, which Node.js documentation flags as a separate path from HTTP requests.
Safe fixes
- •Register a single SIGTERM handler that flips a "shutting down" flag, fails readiness immediately, and then awaits server.close() with a hard timeout before process.exit(1).
- •Wrap the shutdown in a Promise.race between the server close callback and a setTimeout(forceExitMs); set forceExitMs to terminationGracePeriodSeconds minus a safety margin such as 5 seconds.
- •Close external clients (DB pool, Redis, broker channel) only after the HTTP server has emitted 'close', so in-flight queries still have a live connection.
- •Clear every recurring timer and cancel every queue consumer in a tracked list before the final exit, so the event loop can become idle.
- •Make the readiness probe read the "shutting down" flag and return a non-2xx status with a small Retry-After header, so upstream stops sending new traffic.
- •Adjust terminationGracePeriodSeconds in the pod spec to match the longest expected request plus the client close duration, verified by the drain time observed in logs.
Prove the fix
- 01Trigger a rolling restart and confirm, from the orchestrator events, that the process exited with code 0 after the close event, not via SIGKILL.
- 02In the application logs, observe the sequence: "received SIGTERM", "readiness=not-ready", "server closing", "server closed", "pool drained", "exited"; the gap between the first and last line is the drain duration.
- 03Compare the number of 5xx responses during the rolling restart window to the baseline; the fix is validated only if the count does not increase.
- 04Inspect the database slow query or broker unacknowledged logs during the restart window; no transaction should be left open or message redelivered.
- 05Verify readiness flipped to not-ready before the first new request was rejected, by checking that the load balancer recorded zero new requests to the draining instance after the flip.
- 06Run a load test that issues a long request (set to half the grace period) and a concurrent deploy, then confirm the long request completes with a 2xx response and the process exits cleanly.
Prevention and next steps
- •Define a single shutdown() function that closes resources in a known order: readiness=not-ready, stop accepting, drain, close clients, exit; reuse it for SIGTERM, SIGINT, and health-check failures.
- •Keep the drain timeout smaller than the orchestrator's terminationGracePeriodSeconds, and surface the remaining time as a metric so drains approaching the limit are visible.
- •Add a regression test that starts the server, sends SIGTERM, and asserts the server stops accepting new connections within a fixed budget while the existing request completes.
- •Track active timers, intervals, and queue consumers in a registry that the shutdown function iterates, so new background work is automatically cleaned up.
- •Review the Node.js errors reference whenever a new error class appears during shutdown, to confirm it is expected (for example, ECONNRESET from a client that disconnected during drain) and not a leak.
Safe commands and checks
ps -o pid,etime,cmd -p <pid>
grep -nE 'SIGTERM|SIGINT|server.close|server closed|pool drained|shutting down' /var/log/<service>.log
grep -nE 'ECONNRESET|ECONNABORTED|ERR_SERVER_NOT_RUNNING' /var/log/<service>.log
node -e "console.log(process.eventNames().filter(n => n.startsWith('SIG')))"
kill -TERM <pid> && sleep 5 && ps -o pid,stat,etime,cmd -p <pid>