
Stop Brutal Restarts: Implement Graceful Shutdown
Stop dropping user requests during deployments. Learn how to implement graceful shutdown in your Node.js backend to ensure zero-downtime updates.
Have you ever deployed a minor bug fix to production, only to see a brief spike in 502 Bad Gateway errors or user complaints about dropped connections? If your application handles a decent amount of traffic, restarting your server is a violent act.
When Kubernetes, Docker, or PM2 rolls out a new version of your app, they don't just gently ask your old containers to step aside. They send a SIGTERM signal. If your app doesn't explicitly handle this signal, it gets brutally terminated. Any user whose request was mid-flight—perhaps finalizing a payment or uploading a file—gets their connection abruptly severed.
This is the exact opposite of a zero-downtime deployment. Today, we will fix this architectural flaw by implementing graceful shutdown.
The Anatomy of a Violent Restart
Let's look at a standard, naive Node.js Express setup:
import express from 'express';
import { connectDB } from './db';
const app = express();
app.get('/api/heavy-task', async (req, res) => {
// A simulated task taking 5 seconds
await new Promise(resolve => setTimeout(resolve, 5000));
res.send({ status: 'done' });
});
app.listen(3000, async () => {
await connectDB();
console.log('Server is running on port 3000');
});
What happens when you deploy while someone is calling /api/heavy-task?
- The orchestrator sends
SIGTERMto the Node.js process. - Node.js's default behavior for
SIGTERMis to exit immediately. - The 5-second task is violently interrupted at second 2.
- The client receives an abrupt TCP connection reset (
ECONNRESET). - If that task was writing to a database without a transaction, you might now have corrupted or partial data.
We need to teach our application some manners.
What is Graceful Shutdown?

A graceful shutdown follows a specific, polite choreography when it receives a termination signal:
- Stop listening: Stop accepting any new incoming requests from the load balancer.
- Finish ongoing work: Let currently processing requests complete naturally and send their responses back to the clients.
- Clean up resources: Close database connection pools, flush logs, and clear pending background jobs.
- Exit: Terminate the Node.js process cleanly with a zero exit code.
Implementing Graceful Shutdown in Node.js

Here is how you actually do it in a real-world scenario. We need to attach event listeners to the global process object and manage the HTTP server instance explicitly.
import express from 'express';
import { connectDB, closeDB } from './db';
import http from 'http';
const app = express();
// ... routes here ...
const server = http.createServer(app);
server.listen(3000, async () => {
await connectDB();
console.log('Server is running on port 3000');
});
// The Graceful Shutdown logic
const shutdown = async (signal: string) => {
console.log(`Received ${signal}. Starting graceful shutdown...`);
// 1. Stop accepting new requests
server.close(async (err) => {
if (err) {
console.error('Error while closing the server:', err);
process.exit(1);
}
console.log('HTTP server closed. Cleaning up resources...');
try {
// 2. Close database connections and other resources
await closeDB();
console.log('Database connections closed cleanly.');
// 3. Exit process gracefully
process.exit(0);
} catch (e) {
console.error('Error during cleanup:', e);
process.exit(1);
}
});
// Force shutdown if it takes too long
setTimeout(() => {
console.error('Could not close connections in time, forcefully shutting down');
process.exit(1);
}, 10000); // 10 seconds timeout
};
// Listen for termination signals
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT')); // For Ctrl+C in local dev
The Keep-Alive Trap
There is one hidden trap with Express and Node.js that has frustrated many developers. HTTP Keep-Alive connections can keep the server open even if there are no active HTTP requests currently processing.
Browsers and load balancers (like Nginx or AWS ALB) often keep a TCP socket open after a request finishes, just in case they need to send another request soon. The problem? Node.js's server.close() waits for all TCP connections to end before it considers the server fully closed. If an idle client keeps the connection alive, your graceful shutdown will stall until the connection times out.
In older versions of Node.js, we had to manually track sockets in a Map and destroy them during shutdown. Fortunately, modern Node.js (v18.20+ and v20+) introduced native methods to handle this elegantly:
// Inside your shutdown function:
server.closeIdleConnections();
server.close(async (err) => {
// ... cleanup logic ...
});
By calling server.closeIdleConnections(), Node.js immediately drops TCP connections that aren't actively processing a request, allowing server.close() to resolve as soon as the genuinely active requests are done.
The Crucial Timeout
Did you notice the setTimeout block at the end of the shutdown script? That is incredibly important.
Sometimes, a client request might hang indefinitely, or a database query might get deadlocked. If you wait for all connections to close naturally, your graceful shutdown might hang forever. Orchestrators like Kubernetes don't have infinite patience. If your pod doesn't shut down within a specific window (usually 30 seconds by default), Kubernetes will send a ruthless SIGKILL, which cannot be caught or handled.
By setting your own internal timeout (e.g., 10 seconds), you stay in control. You can log an error that the shutdown timed out, which gives you visibility into the issue, rather than just disappearing silently when K8s pulls the plug.
Background Jobs and Queues
If your application isn't just an API server but also processes background jobs (using tools like BullMQ or RabbitMQ), you must extend your shutdown logic to these workers.
When SIGTERM arrives, you need to tell your queue consumer to stop fetching new jobs. However, it must finish processing the current job before closing the connection to Redis or RabbitMQ. Throwing away a half-processed background job can be just as disastrous as dropping an HTTP request, often leading to inconsistent database states or duplicate emails being sent.
The Takeaway
We spend hours tweaking our CI/CD pipelines, configuring load balancers, and setting up rolling deployments to achieve true zero-downtime architecture. But if our application code drops the ball on the very last step—shutting down cleanly—our users still suffer from those fleeting connection errors.
Adding graceful shutdown logic takes minimal effort but yields massive reliability gains. It prevents data corruption, eliminates random 502 errors during routine deployments, and makes your application a robust, mature citizen in modern containerized environments. Never let your apps die a violent death; teach them to say goodbye gracefully.
written by
Nguyên Tech
Responses
Loading comments…