Node.js Graceful Shutdown: Managing Express Signals and Requests
Master Node.js graceful shutdown patterns to prevent dropped requests during deployments. Learn to trap SIGTERM, drain connections, and clean up async tasks.
When you deploy a new version of your app, the orchestrator—usually Kubernetes or Docker—sends a termination signal to your container. If your Express server just dies instantly, every request currently in flight gets a connection reset error. I learned this the hard way during an on-call rotation when our error rates spiked by roughly 15% every time we pushed a fix.
Implementing a Node.js graceful shutdown is the only way to avoid that churn. It’s about catching the signal, stopping new traffic, and letting existing work finish.
Trapping the Right Signals
In a Linux environment, processes receive signals to manage their lifecycle. The two that matter most for a Node.js graceful shutdown are SIGTERM and SIGINT. SIGTERM is the standard "please shut down" from your orchestrator, while SIGINT usually comes from a manual Ctrl+C in the terminal.
We need to listen for these at the process level:
JAVASCRIPTconst server = app.listen(3000); const shutdown = () => { console.log(CE9178">'Shutdown signal received. Starting cleanup...'); // Add logic here }; process.on(CE9178">'SIGTERM', shutdown); process.on(CE9178">'SIGINT', shutdown);
The Logic of Server Connection Draining
Simply calling process.exit() is a recipe for disaster. You need to close the HTTP server first so it stops accepting new connections but keeps the existing ones open. This is called server connection draining.
If you're already familiar with Node.js Async Error Handling in Express: A Practical Guide, you know that keeping your event loop healthy is vital. During a shutdown, we want to ensure any pending database queries or third-party API calls have a chance to wrap up.
Here is how I typically structure the shutdown function:
JAVASCRIPTconst shutdown = async () => { server.close(async (err) => { if (err) { console.error(CE9178">'Error during server close:', err); process.exit(1); } try { // 1. Close DB connections await db.disconnect(); // 2. Clear timers or background queues console.log(CE9178">'Cleanup complete.'); process.exit(0); } catch (error) { console.error(CE9178">'Cleanup failed:', error); process.exit(1); } }); };
Handling Async Cleanup Patterns
Often, your app has background tasks, like message queue consumers or file system writes. If you just exit, you'll leave those in an inconsistent state. I’ve found that using a timeout wrapper is essential here. Sometimes, a hung database query refuses to resolve, and you don't want your deployment to hang indefinitely waiting for it.
| Scenario | Strategy |
|---|---|
| HTTP Traffic | server.close() to stop accepting new requests |
| Database | await pool.end() to finish active queries |
| Redis/Caches | await redis.quit() to flush buffers |
| Background Jobs | Timeout after 10s to force exit |
If you haven't yet, check out Mastering Express Async Middleware: A Guide to AsyncHandler Wrappers to see how those wrappers help manage async flow control before the shutdown even begins.
The "Wrong Way" That Taught Me
We initially tried just calling process.exit() immediately on SIGTERM. It seemed fine until we realized our payment processing service was getting cut off mid-transaction. We had to implement a "draining state" flag. If a request comes in after the shutdown signal, we return a 503 Service Unavailable so the load balancer knows to route traffic elsewhere.
Using Express.js Async Timeout: Prevent Middleware Hangs and Socket Stalls effectively complements this, as it ensures you aren't waiting on a stalled socket that will never resolve during your cleanup period.
Lessons Learned
One thing I'm still tweaking is the exact duration of the timeout. Kubernetes usually gives you 30 seconds before it sends a SIGKILL (which you can't catch). I've found that setting your internal cleanup timeout to about 25 seconds is the sweet spot. It gives your app enough time to finish, but leaves enough buffer for the OS to finalize the process.
I’d also recommend logging the state of your pending requests during the shutdown. If you see consistently high counts of active connections during a restart, you might have a bottleneck that needs investigation. Graceful shutdowns aren't just about stopping; they're a window into how your app handles its final moments.
