A complete TypeScript implementation for gracefully shutting down Node.js HTTP servers, handling OS signals, tracking in-flight requests, and ensuring all connections complete before exit.
import http, { Server, IncomingMessage, ServerResponse } from "node:http";
import { AddressInfo } from "node:net";
interface GracefulShutdownOptions {
server: Server;
timeout?: number;
signals?: NodeJS.Signals[];
onShutdownStart?: () => void | Promise<void>;
onShutdownComplete?: () => void | Promise<void>;
onTimeout?: () => void | Promise<void>;
logger?: {
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
};
}
interface ShutdownState {
isShuttingDown: boolean;
activeConnections: Set<http.ServerResponse>;
connectionCount: number;
}
function createGracefulShutdown(options: GracefulShutdownOptions): {
state: Readonly<ShutdownState>;
shutdown: (signal?: string) => Promise<void>;
trackConnection: (req: IncomingMessage, res: ServerResponse) => void;
} {
const {
server,
timeout = 30000,
signals = ["SIGTERM", "SIGINT"],
onShutdownStart,
onShutdownComplete,
onTimeout,
logger = {
info: console.log,
warn: console.warn,
error: console.error,
},
} = options;
const state: ShutdownState = {
isShuttingDown: false,
activeConnections: new Set<ServerResponse>(),
connectionCount: 0,
};
const trackConnection = (req: IncomingMessage, res: ServerResponse): void => {
if (state.isShuttingDown) {
res.setHeader("Connection", "close");
}
state.activeConnections.add(res);
state.connectionCount++;
const cleanup = (): void => {
state.activeConnections.delete(res);
};
res.on("finish", cleanup);
res.on("close", cleanup);
};
const shutdown = async (signal?: string): Promise<void> => {
if (state.isShuttingDown) {
logger.warn("Shutdown already in progress");
return;
}
state.isShuttingDown = true;
logger.info(`Graceful shutdown initiated${signal ? ` (${signal})` : ""}`);
try {
await onShutdownStart?.();
} catch (error) {
logger.error(`Error in onShutdownStart: ${error}`);
}
const forceExitTimeout = setTimeout(async () => {
logger.warn(`Shutdown timeout (${timeout}ms) exceeded, forcing exit`);
logger.warn(`Abandoning ${state.activeConnections.size} active connections`);
try {
await onTimeout?.();
} catch (error) {
logger.error(`Error in onTimeout: ${error}`);
}
process.exit(1);
}, timeout);
forceExitTimeout.unref();
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
logger.info("Server stopped accepting new connections");
if (state.activeConnections.size > 0) {
logger.info(`Waiting for ${state.activeConnections.size} active connections to complete`);
await new Promise<void>((resolve) => {
const checkConnections = setInterval(() => {
if (state.activeConnections.size === 0) {
clearInterval(checkConnections);
resolve();
}
}, 100);
});
}
clearTimeout(forceExitTimeout);
logger.info("All connections completed");
try {
await onShutdownComplete?.();
} catch (error) {
logger.error(`Error in onShutdownComplete: ${error}`);
}
logger.info(`Graceful shutdown complete (handled ${state.connectionCount} total connections)`);
process.exit(0);
};
for (const signal of signals) {
process.on(signal, () => {
shutdown(signal).catch((error) => {
logger.error(`Shutdown error: ${error}`);
process.exit(1);
});
});
}
return {
state: state as Readonly<ShutdownState>,
shutdown,
trackConnection,
};
}
const server = http.createServer();
const graceful = createGracefulShutdown({
server,
timeout: 30000,
signals: ["SIGTERM", "SIGINT"],
onShutdownStart: async () => {
console.log("Closing database connections...");
await new Promise((resolve) => setTimeout(resolve, 100));
},
onShutdownComplete: async () => {
console.log("Cleanup complete");
},
onTimeout: async () => {
console.log("Performing emergency cleanup");
},
});
server.on("request", (req: IncomingMessage, res: ServerResponse) => {
graceful.trackConnection(req, res);
if (graceful.state.isShuttingDown) {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Server is shutting down" }));
return;
}
const delay = Math.random() * 2000;
setTimeout(() => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
message: "Hello, World!",
processedIn: `${delay.toFixed(0)}ms`,
activeConnections: graceful.state.activeConnections.size
}));
}, delay);
});
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
server.listen(PORT, () => {
const address = server.address() as AddressInfo;
console.log(`Server running on http://localhost:${address.port}`);
console.log("Press Ctrl+C to initiate graceful shutdown");
});This graceful shutdown implementation solves a critical production problem: ensuring your server can restart or scale without dropping user requests. When Kubernetes sends SIGTERM or you press Ctrl+C, the handler stops accepting new connections while allowing existing requests to complete naturally.
The core mechanism uses three key components working together. First, a connection tracker maintains a Set of active ServerResponse objects, adding them when requests arrive and removing them when responses finish or connections close. Second, signal handlers intercept SIGTERM and SIGINT to trigger the shutdown sequence. Third, a timeout mechanism ensures the process eventually exits even if connections hang, preventing zombie processes in your infrastructure.
The shutdown sequence follows a specific order for reliability: mark the server as shutting down (so new requests get 503 responses), call server.close() to stop accepting connections, wait for the active connection set to drain, then run cleanup callbacks and exit. The Connection: close header tells clients not to reuse the connection, helping the drain process complete faster.
Edge cases receive careful handling throughout. Double-shutdown attempts are ignored to prevent race conditions. The timeout uses unref() so it doesn't keep the process alive unnecessarily. Both 'finish' and 'close' events are tracked because connections can terminate either way. The state object is exposed as Readonly to prevent external mutation while still allowing monitoring.
Use this pattern for any production Node.js server, especially in containerized environments where graceful termination affects user experience and deployment speed. Avoid it only for simple scripts or development servers where immediate termination is acceptable. For Express or Fastify, wrap this around their underlying http.Server instance accessed via app.listen() return value or server property.