Graceful Shutdown
Handle SIGTERM and SIGINT by stopping new work, draining HTTP connections, and closing database pools before exit on Node.js 24.
Recipe
Quick-reference recipe card - copy-paste ready.
import express from "express";
import { Pool } from "pg";
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const server = app.listen(Number(process.env.PORT ?? 3000), "0.0.0.0");
let shuttingDown = false;
app.get("/ready", (_req, res) => {
if (shuttingDown) return res.status(503).json({ status: "draining" });
res.json({ status: "ready" });
});
function shutdown(signal: string) {
shuttingDown = true;
console.log(JSON.stringify({ event: "shutdown_start", signal }));
server.close(async () => {
await pool.end();
console.log(JSON.stringify({ event: "shutdown_complete" }));
process.exit(0);
});
setTimeout(() => {
console.error(JSON.stringify({ event: "shutdown_forced" }));
process.exit(1);
}, 30_000).unref();
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));When to reach for this: Every production HTTP service on Kubernetes, ECS, Cloud Run, PM2, or systemd.
Working Example
// src/shutdown.ts
import type { Server } from "node:http";
import type { Pool } from "pg";
export type Closable = { close: () => Promise<void> };
export function registerGracefulShutdown(
server: Server,
resources: Closable[],
options: { timeoutMs?: number } = {}
) {
const timeoutMs = options.timeoutMs ?? 30_000;
let draining = false;
const shutdown = async (signal: string) => {
if (draining) return;
draining = true;
console.log(JSON.stringify({ event: "shutdown_start", signal }));
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
for (const r of resources) {
await r.close();
}
console.log(JSON.stringify({ event: "shutdown_complete" }));
process.exit(0);
};
const forceTimer = setTimeout(() => {
console.error(JSON.stringify({ event: "shutdown_timeout" }));
process.exit(1);
}, timeoutMs);
forceTimer.unref();
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
return {
isDraining: () => draining,
};
}
// src/main.ts
import express from "express";
import { Pool } from "pg";
import { registerGracefulShutdown } from "./shutdown.js";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
const shutdownCtl = registerGracefulShutdown(
app.listen(3000, "0.0.0.0"),
[{ close: () => pool.end() }]
);
app.get("/ready", (_req, res) => {
if (shutdownCtl.isDraining()) {
res.status(503).json({ status: "draining" });
return;
}
res.json({ status: "ready" });
});What this demonstrates:
server.close()stops accepting; finishes in-flight requestspool.end()drains DB connections cleanly- Readiness fails during drain so load balancers stop sending traffic
- Forced exit after 30s matches typical
terminationGracePeriodSeconds
Deep Dive
Signal Timeline on Kubernetes
1. Pod marked Terminating
2. Endpoints removed from Service (readiness fails)
3. preStop hook runs (optional sleep)
4. SIGTERM sent to container
5. grace period elapses -> SIGKILL
Align app shutdown timeout with pod terminationGracePeriodSeconds (app timeout < grace period).
Fastify Shutdown
import Fastify from "fastify";
const app = Fastify();
app.addHook("onClose", async () => {
await pool.end();
});
const close = async () => {
await app.close();
process.exit(0);
};
process.on("SIGTERM", () => void close());app.close() triggers onClose hooks in reverse registration order.
In-Flight Work Beyond HTTP
| Resource | Close API |
|---|---|
PostgreSQL pg.Pool | pool.end() |
Redis ioredis | redis.quit() |
| BullMQ worker | worker.close() |
Cron setInterval | clearInterval + await last job |
Track background jobs; server.close() alone does not wait for queue workers.
@godaddy/terminus
import { createTerminus } from "@godaddy/terminus";
createTerminus(server, {
signals: ["SIGTERM", "SIGINT"],
healthChecks: { "/health": () => Promise.resolve() },
onSignal: async () => { await pool.end(); },
});Bundles health checks and shutdown for Express apps.
Gotchas
- No readiness fail on shutdown - LB sends traffic to dying pod. Fix:
shuttingDownflag on/ready. server.close()without timeout - hang forever on keep-alive. Fix: force exit timer; tunekeepAliveTimeouton server.- Closing pool before HTTP drain - in-flight requests error. Fix:
server.close()first, then pools. - SIGTERM ignored in dev - Ctrl+C sends SIGINT; handle both.
- Workers keep consuming after HTTP stops - duplicate processing during deploy. Fix: pause workers on SIGTERM.
- PM2
kill_timeouttoo short - hard kill mid-request. Fix: 30skill_timeout- PM2 & systemd.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Manual SIGTERM handler | Full control, any framework | Want batteries-included health + drain |
| @godaddy/terminus | Express HTTP services | Fastify (use app.close()) |
| Kubernetes only (no app handler) | Never | Production APIs (always handle SIGTERM) |
Immediate process.exit(0) | Batch jobs | HTTP services (causes 502s) |
FAQs
How long should preStop sleep be?
3-5 seconds often enough for endpoint propagation before SIGTERM. Tune with load balancer docs.
Does Cloud Run send SIGTERM?
Yes on instance scale-in and revision deploy. Same handler pattern applies.
Lambda needs graceful shutdown?
Short-lived invocations; callbackWaitsForEmptyEventLoop matters more than SIGTERM. See Lambda Handler Patterns.
WebSocket connections?
Close WebSocket server in shutdown sequence; notify clients with close frame. May need longer grace period.
NestJS?
app.enableShutdownHooks() listens for SIGTERM and closes modules implementing OnModuleDestroy.
What exit code on forced shutdown?
Exit 1 on timeout so supervisor logs abnormal termination and alerts fire.
Related
- Zero-Downtime Deploys - rollout + drain
- Health & Readiness Probes - readiness during drain
- Kubernetes Deployment - preStop and grace period
- Runtime Ops Basics - supervision model
- Runtime Ops Best Practices - section checklist
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.