Graceful Shutdown
Handle SIGTERM and SIGINT by stopping new work, draining HTTP connections, and closing database pools before exit on Node.js 24.
Search across all documentation pages
Handle SIGTERM and SIGINT by stopping new work, draining HTTP connections, and closing database pools before exit on Node.js 24.
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.
// 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 cleanlyterminationGracePeriodSeconds1. 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).
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.
| 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/terminusimport { 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.
shuttingDown flag on /ready.server.close() without timeout - hang forever on keep-alive. Fix: force exit timer; tune keepAliveTimeout on server.server.close() first, then pools.kill_timeout too short - hard kill mid-request. Fix: 30s kill_timeout - PM2 & systemd.| 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) |
3-5 seconds often enough for endpoint propagation before SIGTERM. Tune with load balancer docs.
Yes on instance scale-in and revision deploy. Same handler pattern applies.
Short-lived invocations; callbackWaitsForEmptyEventLoop matters more than SIGTERM. See Lambda Handler Patterns.
Close WebSocket server in shutdown sequence; notify clients with close frame. May need longer grace period.
app.enableShutdownHooks() listens for SIGTERM and closes modules implementing OnModuleDestroy.
Exit 1 on timeout so supervisor logs abnormal termination and alerts fire.
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.
Reviewed by Chris St. John·Last updated Jul 19, 2026