Implementing Graceful Shutdown and Process Handling in Node.js
Mastering robust process lifecycle management in Node.js by implementing graceful shutdowns, handling SIGTERM and SIGINT signals, and closing database connections cleanly.
In production environments, backend Node.js applications frequently run inside containerized orchestrators like Kubernetes, Docker Swarm, or systemd services. When scaling down, deploying new revisions, or restarting instances, orchestrators terminate container processes by sending specific operating system signals.
If an Express.js or Fastify server terminates abruptly without handling these signals, active HTTP connections are cut off mid-request, database transaction pools are dropped prematurely, and background job queues can be left in corrupted states.
Implementing a robust graceful shutdown mechanism ensures that your application stops accepting new requests, finishes processing inflight operations, closes database pools cleanly, and exits with a zero status code.
SIGTERM vs. SIGINT: Operating System Lifecycle Signals
Node.js applications running inside Unix environments communicate with the operating system through process signals. The two most critical signals for backend lifecycle management are:
• SIGTERM (Signal Terminate): The standard signal sent by container orchestrators like Kubernetes or Docker when stopping a container. It requests polite termination, giving the process time to clean up resources before exiting.
• SIGINT (Signal Interrupt): Triggered locally when a developer presses `Ctrl + C` in the terminal during development. Catching SIGINT ensures local development servers shut down cleanly without leaving orphaned database connections or hanging port bindings.
Building a Graceful Shutdown Wrapper in Express
To implement a graceful shutdown, you must capture the HTTP server instance returned by `app.listen()`, listen for `SIGTERM` and `SIGINT` signals, stop accepting new connections, and drain existing requests before closing database connections.
import express, { Request, Response } from 'express';
import http from 'http';
const app = express();
const server = http.createServer(app);
app.get('/health', (req: Request, res: Response) => {
res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
});
const PORT = process.env.PORT || 4000;
server.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
// Simulated database disconnection function
async function closeDatabaseConnections(): Promise<void> {
console.log('Closing database connection pools...');
// e.g., await prisma.$disconnect(); or await mongoose.connection.close();
return new Promise((resolve) => setTimeout(resolve, 1000));
}
// Graceful shutdown handler function
const handleShutdown = async (signal: string) => {
console.log(`\nReceived signal ${signal}. Starting graceful shutdown...`);
// 1. Stop accepting new HTTP requests
server.close(async () => {
console.log('HTTP server closed. No longer accepting new connections.');
try {
// 2. Close database pools and external cache connections
await closeDatabaseConnections();
console.log('Database connections closed successfully.');
// 3. Exit process cleanly
console.log('Graceful shutdown completed. Exiting process.');
process.exit(0);
} catch (error) {
console.error('Error during shutdown cleanup:', error);
process.exit(1);
}
});
// Force shutdown if cleanup takes too long (e.g., 10 seconds timeout)
setTimeout(() => {
console.error('Could not close connections in time, forcefully shutting down');
process.exit(1);
}, 10000);
};
// Register signal listeners
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
process.on('SIGINT', () => handleShutdown('SIGINT'));
Managing Unhandled Promise Rejections and Exceptions
In addition to operating system termination signals, robust Node.js applications must handle unexpected runtime errors gracefully to prevent silent failures or corrupted application states.
When an unhandled promise rejection or uncaught exception occurs, the application state is often compromised. Rather than attempting to continue execution, the best practice is to log the error, initiate a graceful shutdown, and let your process manager restart a fresh container instance.
process.on('unhandledRejection', (reason: Error) => {
console.error('Unhandled Rejection detected:', reason);
// Trigger graceful shutdown
handleShutdown('UNHANDLED_REJECTION');
});
process.on('uncaughtException', (error: Error) => {
console.error('Uncaught Exception detected:', error);
// Uncaught exceptions leave app in undefined state; exit immediately after cleanup
handleShutdown('UNCAUGHT_EXCEPTION');
});
Summary
Implementing graceful shutdown and robust process handling in Node.js is essential for building resilient, production-ready backend services that integrate seamlessly with container orchestrators.
By capturing `SIGTERM` and `SIGINT` signals, closing HTTP servers cleanly, draining active requests, and terminating database connection pools without data loss, engineering teams can ensure zero-downtime deployments and highly reliable web applications.