Why Graceful Shutdown?
When a server is terminated (deployment restart, system shutdown, crash), ongoing requests are abruptly cut off:
Without graceful shutdown: With graceful shutdown:
│
Client sending data │ Server receives SIGTERM
│ │
▼ ▼ Server stops accepting new requests
Server crashes/killed │
│ │ Existing requests complete normally
▼ │
Active requests aborted ────────────→│ New connections rejected
Database connections dropped │
Data corruption possible │ Database connections closed cleanly
│
▼ Server exits
A graceful shutdown:
- Stops accepting new requests
- Waits for in-flight requests to finish (with a timeout)
- Closes database connections
- Releases other resources
- Exits the process
Basic Graceful Shutdown
const express = require('express');
const mongoose = require('mongoose');
const app = express();
let server;
// Start server
async function start() {
await mongoose.connect(process.env.MONGO_URI);
console.log('Database connected');
server = app.listen(3000, () => {
console.log('Server running on port 3000');
});
}
start();
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received. Shutting down gracefully...');
shutdown();
});
process.on('SIGINT', () => {
console.log('SIGINT received. Shutting down gracefully...');
shutdown();
});
function shutdown() {
// 1. Stop accepting new connections
server.close(async () => {
console.log('HTTP server closed');
// 2. Close database connections
await mongoose.connection.close();
console.log('Database connection closed');
// 3. Exit
process.exit(0);
});
}
Production-Grade Graceful Shutdown
// config/gracefulShutdown.js
function gracefulShutdown(server, connections = []) {
const shutdownTimeout = 30000; // 30 second max
return async (signal) => {
console.log(`\n${signal} received. Starting graceful shutdown...`);
let shutdownTimer = setTimeout(() => {
console.error('Shutdown timed out. Forcing exit.');
process.exit(1);
}, shutdownTimeout);
try {
// 1. Stop accepting new requests
await new Promise((resolve) => server.close(resolve));
console.log('✓ No longer accepting new connections');
// 2. Wait for in-flight requests to complete
await waitForPendingRequests(server);
console.log('✓ All pending requests completed');
// 3. Close database connections
for (const conn of connections) {
if (typeof conn.close === 'function') {
await conn.close();
console.log(`✓ ${conn.name || 'Database'} connection closed`);
}
}
// 4. Clear shutdown timer
clearTimeout(shutdownTimer);
console.log('Graceful shutdown complete');
process.exit(0);
} catch (err) {
console.error('Error during shutdown:', err);
process.exit(1);
}
};
}
function waitForPendingRequests(server) {
return new Promise((resolve) => {
const pending = server._connections || 0;
if (pending === 0) {
resolve();
} else {
console.log(`Waiting for ${pending} pending requests...`);
server.on('close', resolve);
}
});
}
module.exports = gracefulShutdown;
Using It
// index.js
const express = require('express');
const mongoose = require('mongoose');
const Redis = require('ioredis');
const gracefulShutdown = require('./config/gracefulShutdown');
const app = express();
let server;
const redis = new Redis(process.env.REDIS_URL);
async function start() {
await mongoose.connect(process.env.MONGO_URI);
server = app.listen(3000, () => console.log('Server ready'));
// Register shutdown handlers
const shutdown = gracefulShutdown(server, [
mongoose.connection,
redis
]);
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
start();
Full Production Example
const express = require('express');
const mongoose = require('mongoose');
const Redis = require('ioredis');
const winston = require('winston');
const app = express();
let server;
let isShuttingDown = false;
// Logger
const logger = winston.createLogger({
level: 'info',
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'logs/error.log', level: 'error' })
]
});
// Connections
let redis;
// Health check endpoint
app.get('/health', (req, res) => {
if (isShuttingDown) {
return res.status(503).json({ status: 'shutting_down' });
}
res.json({
status: 'ok',
uptime: process.uptime(),
memory: process.memoryUsage()
});
});
// Reject new requests during shutdown
app.use((req, res, next) => {
if (isShuttingDown) {
return res.status(503).json({
error: 'Server is shutting down',
retryAfter: 30
});
}
next();
});
// Routes
app.get('/api/users', async (req, res) => {
const users = await User.find();
res.json(users);
});
// Start server
async function start() {
try {
// Connect to MongoDB
await mongoose.connect(process.env.MONGO_URI);
logger.info('MongoDB connected');
// Connect to Redis
redis = new Redis(process.env.REDIS_URL);
logger.info('Redis connected');
// Start HTTP server
server = app.listen(process.env.PORT || 3000, () => {
logger.info(`Server listening on port ${process.env.PORT || 3000}`);
});
// Register shutdown handlers
registerShutdownHandlers();
} catch (err) {
logger.error('Failed to start server', { error: err.message });
process.exit(1);
}
}
function registerShutdownHandlers() {
const shutdown = async (signal) => {
if (isShuttingDown) return; // Prevent multiple calls
isShuttingDown = true;
logger.info(`${signal} received - starting graceful shutdown`);
const forceExit = setTimeout(() => {
logger.error('Forced shutdown after timeout');
process.exit(1);
}, 30000); // 30 second budget
try {
// 1. Stop accepting new connections
await new Promise((resolve) => server.close(resolve));
logger.info('HTTP server closed - no new connections');
// 2. Wait for keep-alive connections to drain
await new Promise((resolve) => setTimeout(resolve, 1000));
// 3. Close MongoDB
if (mongoose.connection.readyState === 1) {
await mongoose.connection.close();
logger.info('MongoDB connection closed');
}
// 4. Close Redis
if (redis && redis.status === 'ready') {
await redis.quit();
logger.info('Redis connection closed');
}
clearTimeout(forceExit);
logger.info('Graceful shutdown complete');
process.exit(0);
} catch (err) {
logger.error('Error during shutdown', { error: err.message });
process.exit(1);
}
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Handle uncaught exceptions & unhandled rejections
process.on('uncaughtException', (err) => {
logger.error('Uncaught exception', { error: err.message, stack: err.stack });
shutdown('UNCAUGHT_EXCEPTION');
});
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled rejection', { reason: reason?.message || reason });
shutdown('UNHANDLED_REJECTION');
});
}
start();
Kubernetes-Ready Shutdown
When running in Kubernetes, the platform sends SIGTERM and expects the app to shut down within a grace period:
// config/k8s-shutdown.js
function setupKubernetesShutdown(server, cleanupFns = []) {
const shutdownGracePeriod = parseInt(process.env.SHUTDOWN_GRACE_PERIOD) || 25000; // 25s
let isShuttingDown = false;
// Kubernetes sends SIGTERM
process.on('SIGTERM', async () => {
if (isShuttingDown) return;
isShuttingDown = true;
console.log('Kubernetes termination signal received');
const timer = setTimeout(() => {
console.error('Shutdown grace period exceeded');
process.exit(1);
}, shutdownGracePeriod);
try {
// Stop accepting new connections
await new Promise((resolve) => server.close(resolve));
// Drain existing connections
await new Promise((resolve) => {
const check = () => {
server.getConnections((err, count) => {
if (count === 0) resolve();
else setTimeout(check, 100);
});
};
check();
});
// Run cleanup functions
for (const cleanup of cleanupFns) {
await cleanup();
}
clearTimeout(timer);
console.log('Shutdown complete');
process.exit(0);
} catch (err) {
console.error('Shutdown error:', err);
process.exit(1);
}
});
}
module.exports = setupKubernetesShutdown;
Connection Draining
For WebSocket or long-polling connections:
// Track active connections
const activeConnections = new Set();
app.use((req, res, next) => {
// Track this connection
activeConnections.add(res);
res.on('close', () => {
activeConnections.delete(res);
});
next();
});
// During shutdown: drain active connections
async function drainConnections() {
console.log(`Draining ${activeConnections.size} active connections...`);
// Set a deadline for all connections
const drainTimeout = setTimeout(() => {
activeConnections.forEach((res) => {
if (!res.writableEnded) {
res.status(503).json({ error: 'Server shutting down' });
res.end();
}
});
}, 15000); // 15s drain timeout
// Wait for all connections to finish naturally
return new Promise((resolve) => {
const check = () => {
if (activeConnections.size === 0) {
clearTimeout(drainTimeout);
resolve();
} else {
setTimeout(check, 500);
}
};
check();
});
}
Health Check for Shutdown State
let isShuttingDown = false;
const startTime = Date.now();
app.get('/health', (req, res) => {
if (isShuttingDown) {
return res.status(503).json({
status: 'shutting_down',
uptime: Math.floor((Date.now() - startTime) / 1000)
});
}
res.json({
status: 'healthy',
uptime: Math.floor((Date.now() - startTime) / 1000),
connections: {
mongo: mongoose.connection.readyState === 1,
redis: redis?.status === 'ready'
},
memory: process.memoryUsage()
});
});
Testing Graceful Shutdown
// test/gracefulShutdown.test.js
const request = require('supertest');
describe('Graceful Shutdown', () => {
it('should reject new requests during shutdown', async () => {
// Simulate shutdown state
const app = require('../index');
app.set('isShuttingDown', true);
const res = await request(app)
.get('/health')
.expect(503);
expect(res.body.status).toBe('shutting_down');
});
it('should complete in-flight requests', (done) => {
// Make a slow request, then trigger shutdown
// The slow request should still complete
const slowRes = request(app).get('/slow-endpoint');
// Wait a bit, then trigger shutdown
setTimeout(() => {
process.emit('SIGTERM');
}, 100);
slowRes.then((res) => {
expect(res.status).toBe(200);
done();
});
});
});
Key Takeaways
- Graceful shutdown lets in-flight requests complete before the server exits
- Catch SIGTERM (from orchestrators like Kubernetes) and SIGINT (Ctrl+C)
- Set a shutdown timeout (e.g., 30s) to force exit if cleanup hangs
- Track active connections to drain them during shutdown
- Reject new requests with 503 Service Unavailable during shutdown
- Close database connections (MongoDB, Redis, PostgreSQL) cleanly
- Use a shutdown flag (
isShuttingDown) to prevent duplicate shutdown calls - Handle uncaught exceptions and unhandled rejections by triggering shutdown
- Test the shutdown flow to ensure it works under load