Graceful Shutdown for Node Servers
Ensuring your Node.js applications shut down cleanly is critical for maintaining service availability, preventing data corruption, and facilitating smooth…
Ensuring your Node.js applications shut down cleanly is critical for maintaining service availability, preventing data corruption, and facilitating smooth deployments. Abrupt process termination can lead to dropped client connections, incomplete database transactions, and lingering resources. This article details strategies for implementing graceful shutdowns in Node.js, focusing on handling incoming requests, asynchronous operations, and resource cleanup within a defined timeout period.
Understanding Process Signals and Orchestration
Modern application orchestration platforms like Kubernetes, Docker Swarm, and even traditional init systems (systemd, Upstart) manage process lifecycles using POSIX signals. The two most relevant for graceful shutdown are SIGTERM and SIGKILL.
- SIGTERM (Signal 15): This is a "terminate" signal, requesting a process to shut down. It's the standard way orchestration systems ask your application to stop. The process can catch this signal, clean up, and exit gracefully.
- SIGKILL (Signal 9): This is an "unconditional terminate" signal. It cannot be caught, blocked, or ignored by the process. The operating system immediately terminates the process. This is the fallback for orchestration systems if a process fails to respond to
SIGTERMwithin a specified timeout.
Kubernetes, for instance, sends a SIGTERM to containers marked for termination. It then waits for a duration defined by terminationGracePeriodSeconds (defaulting to 30 seconds). If the process hasn't exited by then, it sends a SIGKILL. This period must be long enough for your application to complete ongoing work and clean up, but not so long as to delay deployments unnecessarily.
Core Strategy: Stop Accepting, Finish Draining
The fundamental principle of a graceful shutdown for a server application is a two-step process:
- Stop accepting new work: Prevent new requests from being processed by the server.
- Finish existing work and clean up: Allow currently active requests to complete and release resources (database connections, file handles, message queue consumers).
Implementing the Shutdown Handler
In Node.js, the http.Server instance has a .close() method that facilitates this. When server.close() is called, the server stops accepting new connections but keeps existing connections alive until they naturally close or time out.
Consider a basic Express.js server as an example:
const express = require('express');
const http = require('http');
const app = express();
const PORT = process.env.PORT || 3000;
let server; // Declare server globally or pass it
// Simulate a long-running request
app.get('/slow', (req, res) => {
console.log(`Received slow request at ${new Date().toISOString()}`);
setTimeout(() => {
res.send('This was a slow response!');
console.log(`Sent slow response at ${new Date().toISOString()}`);
}, 10000); // 10-second delay
});
app.get('/', (req, res) => {
res.send('Hello from Node.js!');
});
function startServer() {
server = http.createServer(app);
server.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
server.on('error', (err) => {
console.error('Server error:', err);
process.exit(1); // Exit on critical server errors
});
}
function shutdown(signal) {
console.log(`\n${signal} received. Initiating graceful shutdown...`);
// Step 1: Stop accepting new connections
server.close((err) => {
if (err) {
console.error('Error closing server:', err);
// Even if there's an error closing the server, we should still try to exit
} else {
console.log('Server stopped accepting new connections.');
}
// Step 2: Clean up other resources and exit
cleanupAndExit();
});
}
async function cleanupAndExit() {
console.log('Performing resource cleanup...');
try {
// Example: Drain database connection pools
// await dbConnection.end(); // Or specific pool.drain() methods
console.log('Database connections closed.');
// Example: Close message queue connections
// await mqConnection.close();
console.log('Message queue connections closed.');
// Add any other async cleanup tasks here
console.log('All resources cleaned up. Exiting process.');
process.exit(0);
} catch (cleanupErr) {
console.error('Error during cleanup:', cleanupErr);
process.exit(1); // Exit with error code if cleanup fails
}
}
// Start the application
startServer();
// Register signal handlers
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT')); // Handle Ctrl+C locally
In this example, the shutdown function first calls server.close(). Once all active connections have drained and the server is no longer listening, the callback passed to server.close() is executed, triggering cleanupAndExit().
Implementing a Forceful Timeout
A crucial component of graceful shutdown is the timeout. If a long-running request or a hung cleanup task prevents the application from exiting within the allotted time, the orchestration system will resort to SIGKILL, potentially leaving resources in an inconsistent state. To avoid this, we implement our own timeout, which, if exceeded, forces the application to exit.
const express = require('express');
const http = require('http');
const app = express();
const PORT = process.env.PORT || 3000;
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = process.env.GRACEFUL_SHUTDOWN_TIMEOUT_MS ? parseInt(process.env.GRACEFUL_SHUTDOWN_TIMEOUT_MS, 10) : 25000; // 25 seconds
let server;
let isShuttingDown = false; // Flag to prevent multiple shutdown attempts
// Simulate a long-running request
app.get('/slow', (req, res) => {
console.log(`Received slow request at ${new Date().toISOString()}`);
setTimeout(() => {
res.send('This was a slow response!');
console.log(`Sent slow response at ${new Date().toISOString()}`);
}, 10000); // 10-second delay
});
app.get('/', (req, res) => {
res.send('Hello from Node.js!');
});
function startServer() {
server = http.createServer(app);
server.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
server.on('error', (err) => {
console.error('Server error:', err);
process.exit(1);
});
}
function shutdown(signal) {
if (isShuttingDown) {
console.log('Shutdown already in progress. Ignoring signal.');
return;
}
isShuttingDown = true;
console.log(`\n${signal} received. Initiating graceful shutdown...`);
// Set a timeout for the entire shutdown process
const shutdownTimeout = setTimeout(() => {
console.error(`Graceful shutdown timed out after ${GRACEFUL_SHUTDOWN_TIMEOUT_MS}ms. Forcing exit.`);
process.exit(1); // Exit with an error code
}, GRACEFUL_SHUTDOWN_TIMEOUT_MS);
server.close((err) => {
if (err) {
console.error('Error closing server:', err);
} else {
console.log('Server stopped accepting new connections.');
}
// Once server.close() callback fires, clear the timeout and proceed with cleanup
clearTimeout(shutdownTimeout);
cleanupAndExit();
});
}
async function cleanupAndExit() {
console.log('Performing resource cleanup...');
try {
// Add all async cleanup tasks here
// Example: await dbConnection.end();
// Example: await mqConnection.close();
// Simulate cleanup work
await new Promise(resolve => setTimeout(resolve, 2000)); // 2-second cleanup
console.log('All resources cleaned up. Exiting process.');
process.exit(0);
} catch (cleanupErr) {
console.error('Error during cleanup:', cleanupErr);
process.exit(1);
}
}
startServer();
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
The GRACEFUL_SHUTDOWN_TIMEOUT_MS should be slightly less than your orchestrator's terminationGracePeriodSeconds to allow your application to perform a controlled error exit (process.exit(1)) rather than being abruptly SIGKILLed. For example, if Kubernetes has a 30-second grace period, set your Node.js timeout to 25-28 seconds.
Handling Open Sockets (Node.js 14+)
Prior to Node.js 18.2.0, server.close() would wait indefinitely for established Keep-Alive connections to terminate. In Node.js 18.2.0+, server.close() now has a default timeout of 5 seconds for existing connections. If your application uses older Node.js versions or requires finer control, you might need to track open connections manually.
A common pattern involves tracking connections and destroying them after a delay:
let connections = new Set();
// In startServer function, before server.listen():
server.on('connection', (socket) => {
connections.add(socket);
socket.on('close', () => connections.delete(socket));
});
// In shutdown function, after server.close() is called:
// Before calling cleanupAndExit(), wait for connections to drain or force close them
setTimeout(() => {
console.log(`Forcefully closing ${connections.size} remaining connections...`);
for (const socket of connections) {
socket.destroy(); // Destroys the socket immediately
}
}, 5000); // Wait 5 seconds for active connections to complete naturally, then force close
Be cautious with aggressively destroying sockets, as it can cut off active client requests. The best approach is to give enough time for active requests to finish.
Considerations for Asynchronous Operations and External Resources
Beyond HTTP connections, your application likely interacts with databases, message queues, file systems, or external APIs. All these connections and pending operations need to be gracefully handled.
- Database Connections: If using connection pools (e.g.,
pg-pool,sequelize,mongoose), ensure you call their respective.end()or.close()methods. These methods typically wait for active queries to complete before releasing connections. - Message Queues (Kafka, RabbitMQ, Redis Pub/Sub): Stop consuming messages, then close producers and consumer connections. Ensure any messages in flight or buffered locally are flushed or acknowledged.
- File Handles: Ensure all open file streams are closed.
- WebSockets/Socket.IO: These require explicit closing of server instances and possibly notifying connected clients.
- Long-Polling/Server-Sent Events: Similar to WebSockets, these connections need to be managed.
Encapsulate these cleanup tasks within your cleanupAndExit function, making it an async function and using await for each cleanup step to ensure they complete sequentially or concurrently as appropriate.
Common Pitfalls
- Insufficient Timeout: The most frequent issue. If your application's longest-running request or cleanup task exceeds the orchestrator's grace period (e.g., 30s in Kubernetes), your app will be
SIGKILLed. Measure your longest processes and setGRACEFUL_SHUTDOWN_TIMEOUT_MSaccordingly, always slightly less than the orchestration platform's timeout. - Not Handling All Signals: While
SIGTERMis primary,SIGINT(Ctrl+C) is common during local development. Ensure both are caught. - Ignoring
server.close()Callback: Just callingserver.close()doesn't mean the server is immediately ready to exit. The callback signifies that all active connections have drained and the server has stopped listening. Cleanup should happen within or after this callback. - Blocking Cleanup Operations: Synchronous operations in
cleanupAndExit()can hang the shutdown process. Ensure all I/O-bound cleanup is asynchronous and awaited. - Not Tracking Open Connections (Older Node.js): For Node.js versions prior to 18.2.0, failing to manually track and destroy lingering connections can lead to
server.close()waiting indefinitely. - Race Conditions: Multiple
SIGTERMsignals or concurrent shutdown attempts can cause issues. Use a flag likeisShuttingDownto prevent re-entry.