Node.js graceful shutdown
Contract
| Field | Bound contract |
|---|---|
| Trigger | Implementing or fixing service termination handling: SIGTERM or SIGINT, connection draining, health-check shutdown signaling, zero-downtime deploys. |
| Authority | Reversible local: writes only the service shutdown handler and a companion test; rollback is version control. No remote mutation. |
| Side effect | Edits server shutdown code; runs a short-lived server to verify drain. |
| Done | On signal the service stops accepting new connections, drains active requests within the deadline, closes external dependencies, exits with code 0 on success or non-zero on deadline, and the companion test passes. |
Inputs
DRAIN_TIMEOUT_MS: integer milliseconds to wait for in-flight requests to complete before forcing exit. Required; must be positive. Default10000.SERVER_PORT: TCP port for the HTTP server. Required.HEALTH_ROUTE: URL path for the health or readiness endpoint. Required.SOURCES: list of source files under$CWD/srcor$CWD/libto edit. Required; may be a single file.EXTERNAL_DEPENDENCIES: list of external connections the application opens (database pools, Redis clients, message-queue producers). Required; may be empty.
Procedure
Install SIGTERM and SIGINT handlers. Read every file in
SOURCES. Identify the server bootstrap point: thehttp.createServeror Express or Fastifylistencall. Identify any existingprocess.onhandlers. If noisShuttingDownboolean state exists, add it aslet isShuttingDown = falseat module scope. Add or augmentprocess.on('SIGTERM', shutdown)andprocess.on('SIGINT', shutdown); if the existing handler is named and registered, update it, do not register a second handler on the same signal. Done when: both SIGTERM and SIGINT are wired to a single shutdown handler andisShuttingDownstate exists at module scope.Flip health check to 503 and verify it responds. Write the
shutdownfunction so that the first action is settingisShuttingDown = true, then update the health endpoint handler to return503 Service Unavailablewith body{"status":"shutting_down"}whenisShuttingDown === true, and200 OKwith body{"status":"ok"}otherwise. The health flip must happen beforeserver.close()so the 503 observation is reliable. Verify by sending a health request immediately after the signal: it must return 503. Done when: the health endpoint returns 503 shutting_down during shutdown and 200 ok otherwise, and the 503 is observable before the server stops accepting connections.Call server.close() and drain active requests. After the health flip, call
server.close()from the variable that holds the server instance. This stops accepting new connections and resolves when all active connections close. Awaitserver.close()with a deadline: race it against a timeout ofDRAIN_TIMEOUT_MS. Ifserver.close()resolves first, the drain succeeded gracefully. If the timeout wins, active requests are still in flight and the exit must be non-zero. Do not use a fixed sleep; await the actual close promise. Done when:server.close()is called and awaited with a deadline, and the drain outcome (success or timeout) is distinguishable.Close external dependencies. Call
closeExternalConnections()for every connection inEXTERNAL_DEPENDENCIES:pool.end()for database pools,redis.quit()for Redis clients,producer.disconnect()for message-queue producers. Do not modify OS-level sockets. Await all close calls; if any dependency is unresponsive, record it and proceed to exit. Done when: every external dependency in the list has a close call in the shutdown function.Exit with code 0 on success or non-zero on deadline. If
server.close()resolved before the timeout and all dependencies closed, callprocess.exit(0). If the timeout won (drain did not complete in time), callprocess.exit(1). If a dependency was unresponsive, callprocess.exit(1). The exit code must distinguish a graceful drain from a deadline-forced termination. Done when: the exit code is 0 on graceful success and non-zero on deadline or partial cleanup.Write a companion test. Create
<filename>.test.tsthat:- Spawns the server with
DRAIN_TIMEOUT_MS=500. - Sends a
GET /<HEALTH_ROUTE>request and asserts200. - Sends
SIGTERMto the server process. - Immediately sends a second health request; asserts
503withshutting_down. - Sends a long-running request before the signal; asserts it completes before the server exits.
- Waits for the process to exit; asserts exit code
0when the long-running request completes within the deadline. - Runs a second scenario where the long-running request exceeds
DRAIN_TIMEOUT_MS; asserts exit code is non-zero. Done when: the companion test file is written covering all assertions.
- Spawns the server with
Run the test. Run with
node --testor the project's test runner. Done when: all tests pass.
Failure and recovery
| Failure class | Result |
|---|---|
| Unresponsive dependency | closeExternalConnections() hangs. Record which dependency did not close. Exit with code 1. The test detects the non-zero exit. Add a timeout on each dependency close call. |
| Drain timeout | Active requests exceed DRAIN_TIMEOUT_MS. The timeout wins the race against server.close(). Exit with code 1. The test detects the non-zero exit. Increase DRAIN_TIMEOUT_MS or ensure the service under test does not hold connections beyond the limit. |
| Partial cleanup | Some dependencies closed, others did not. Record which closed and which did not. Exit with code 1. The test detects the non-zero exit. Add the missing dependency to EXTERNAL_DEPENDENCIES. |
isShuttingDown checked after I/O initiation |
A request that started async work before the signal may still emit a database query after isShuttingDown is set. Add the guard before the I/O call, not after. |
Health route does not reflect isShuttingDown |
The load balancer keeps routing traffic to a pod that has started draining. The health endpoint must return 503 immediately after the signal, before server.close(). |
Output
Edited source files implementing the graceful shutdown sequence, plus a passing companion test demonstrating the 503 health flip, clean drain, and exit code that distinguishes graceful from deadline-forced termination.