Don't Let Your Node.js App Die Ugly: A Guide to Perfect Graceful Shutdowns
Source: Dev.to
We spend hours perfecting our CI/CD pipelines, optimizing database queries, and writing clean code. But we often ignore one crucial part of the application lifecycle: death.
When you deploy a new version of your app to Kubernetes, Docker Swarm, or even PM2, the orchestrator sends a SIGTERM signal to your running process.
If you don’t handle this signal, Node.js might terminate immediately. Active HTTP requests are severed, database connections remain open (ghost connections), and transactions might be left in limbo.
Today, I want to show you how to handle this professionally using ds‑express‑errors, specifically diving deep into its Graceful Shutdown API.
The “Zero‑Dependency” Approach
I built ds‑express‑errors primarily for error mapping, but I included a robust shutdown manager because I was tired of pulling in extra dependencies just to catch a SIGINT.
The library has 0 dependencies, meaning it’s lightweight and secure.
The Full Configuration (Not Just the Basics)
Most tutorials only show you how to close the server. Real production apps need more: they must distinguish between a “Planned Shutdown” (Deployment) and a “Crash” (Uncaught Exception), and they need granular control over exit behavior.
Below is the complete API reference for initGlobalHandlers.
The Setup
Install the package
npm install ds-express-errors
Power‑User configuration (e.g., index.js)
const express = require('express');
const mongoose = require('mongoose');
const {
initGlobalHandlers,
gracefulHttpClose,
} = require('ds-express-errors');
const app = express();
const server = app.listen(3000);
// The Complete Configuration
initGlobalHandlers({
// 1️⃣ HTTP Draining Mechanism
// Wraps server.close() in a promise that waits for active requests to finish.
closeServer: gracefulHttpClose(server),
// 2️⃣ Normal Shutdown Logic (SIGINT, SIGTERM)
// Runs when you redeploy or stop the server manually.
onShutdown: async () => {
console.log('SIGTERM received. Closing external connections...');
// Close DB, Redis, Socket.io, etc.
await mongoose.disconnect();
console.log('Cleanup finished. Exiting.');
},
// 3️⃣ Crash Handling (Uncaught Exceptions / Unhandled Rejections)
// Runs when your code throws an error you didn’t catch.
onCrash: async (error) => {
console.error('CRITICAL ERROR:', error);
// Critical: Send alert to Sentry/Slack/PagerDuty immediately
// await sendAlert(error);
},
// 4️⃣ Exit Strategy for Unhandled Rejections
// Default: true.
// If false, the process continues running even after an unhandled promise rejection.
// (Recommended: true, because an unhandled rejection can leave the app in an unstable state)
exitOnUnhandledRejection: true,
// 5️⃣ Exit Strategy for Uncaught Exceptions
// Default: true.
// If false, the app tries to stay alive after a sync error.
// (High risk of memory leaks or corrupted state if set to false).
exitOnUncaughtException: true,
});
Breakdown of the Options
| Option | Type | Description |
|---|---|---|
closeServer | Function (Async) | Handles the HTTP layer. The helper gracefulHttpClose(server) listens for the abort signal and abstracts the callback‑hell of native server.close(). It stops new connections while allowing existing requests to finish. |
onShutdown | Function (Async) | Business‑logic cleanup triggered only by system signals (SIGINT, SIGTERM, SIGQUIT). This is the “happy path” of death. Typical tasks: • Close database connections • Flush logs • Cancel long‑running jobs |
onCrash | Function (Async) | Triggered by uncaughtException or unhandledRejection. It lets you treat a crash differently from a graceful shutdown (e.g., fire an immediate alert and exit). |
exitOnUnhandledRejection | Boolean (default true) | If true, the process exits with code 1 after an unhandled rejection (fail‑fast). Set to false only if you have a very specific resilience strategy. |
exitOnUncaughtException | Boolean (default true) | Same idea as above, but for synchronous uncaught exceptions. |
Why Separate onShutdown and onCrash?
onShutdown– You’re calm; you have time to gracefully close resources.onCrash– The house is on fire; you may want to send an urgent alert and terminate immediately.
The Safety Net: Timeouts
What if your onShutdown logic hangs? What if mongoose.disconnect() never resolves? You don’t want your pod to stay in the “Terminating” state forever (Kubernetes will eventually SIGKILL it).
ds-express-errors wraps your shutdown logic in a 10‑second timeout. If cleanup exceeds this limit, the library forces termination, ensuring the container doesn’t block the deployment pipeline.
TL;DR
- Use
initGlobalHandlersfrom ds‑express‑errors to get a zero‑dependency, production‑ready graceful shutdown solution. - Separate graceful shutdown (
onShutdown) from crash handling (onCrash). - Leverage the built‑in HTTP draining helper
gracefulHttpClose(server). - Keep the default “fail fast” behavior (
exitOn…=true) unless you have a compelling reason to stay alive after a fatal error.
Now your Node.js app can die gracefully, keeping users happy and your infrastructure clean.
Functions that don’t resolve in time will be forced to exit, preventing zombie processes.
Conclusion
Handling process termination is part of being a senior engineer. It distinguishes a “hobby project” from a reliable distributed system.
With ds-express-errors, you don’t need complex boilerplate. You get a fully typed, zero‑dependency solution that handles both graceful shutdowns and critical crashes out of the box.
Links
Happy coding (and happy shutting down)! 🔌
