Node.js Graceful Shutdown in Production
A deploy to production shouldn't drop requests. But by default, when Kubernetes sends SIGTERM to a Node.js process to roll it over, the process exits immediately — killing any in-flight requests mid-response.
Graceful shutdown is the fix. Here's how to implement it properly in NestJS.
What Happens Without It
During a rolling deploy, Kubernetes:
- Sends
SIGTERMto the old pod - Starts routing traffic to the new pod
- Waits
terminationGracePeriodSeconds(default: 30s), then sendsSIGKILL
Between steps 1 and 2 there's a gap — traffic is still hitting the old pod while it's shutting down. Without graceful shutdown, those requests fail.
The NestJS Built-in
NestJS has a enableShutdownHooks() method that handles SIGTERM and SIGINT, calling onModuleDestroy() on each module before exiting:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Required for graceful shutdown hooks to fire
app.enableShutdownHooks();
await app.listen(3000);
console.log('Application started on port 3000');
}
bootstrap();Any service can then implement OnModuleDestroy:
@Injectable()
export class DatabaseService implements OnModuleDestroy {
async onModuleDestroy() {
await this.dataSource.destroy();
this.logger.log('Database connections closed');
}
}Draining In-Flight Requests
The built-in hooks close the app, but they don't wait for in-flight HTTP requests to complete. For that, you need to stop accepting new connections first, then wait for active requests to drain:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
const server = await app.listen(3000);
// On SIGTERM: stop accepting new connections, drain existing ones
process.on('SIGTERM', async () => {
console.log('SIGTERM received — starting graceful shutdown');
// Stop accepting new HTTP requests
server.close(async () => {
console.log('HTTP server closed');
// Let NestJS close modules (DB connections, queues, etc.)
await app.close();
console.log('Application closed');
process.exit(0);
});
// Force exit if drain takes too long
setTimeout(() => {
console.error('Forced exit after timeout');
process.exit(1);
}, 25_000); // 5s before Kubernetes SIGKILL
});
}
bootstrap();Closing Queue Workers
BullMQ workers should finish their current job before shutting down — not abandon it mid-process:
@Injectable()
export class QueueShutdownService implements OnModuleDestroy {
constructor(
@InjectQueue('orders') private readonly ordersQueue: Queue,
) {}
async onModuleDestroy() {
// Close the queue — no new jobs accepted
await this.ordersQueue.close();
// Wait for active workers to finish their current job
// Worker.close() waits for the current job to complete
await this.ordersWorker.close();
this.logger.log('Queue workers shut down cleanly');
}
}Health Check During Shutdown
Add a /health endpoint that returns 503 once shutdown has started. Kubernetes uses this (via readinessProbe) to stop routing traffic before the process exits:
@Controller('health')
export class HealthController {
private isShuttingDown = false;
constructor() {
process.on('SIGTERM', () => {
this.isShuttingDown = true;
});
}
@Get()
check() {
if (this.isShuttingDown) {
throw new ServiceUnavailableException('Shutting down');
}
return { status: 'ok', timestamp: new Date().toISOString() };
}
}Kubernetes Configuration
spec:
template:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: api
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
lifecycle:
preStop:
exec:
# Give the load balancer time to stop routing traffic
# before SIGTERM is sent
command: ["/bin/sh", "-c", "sleep 5"]The preStop hook adds a 5-second sleep before SIGTERM. This gives the load balancer and Kubernetes enough time to remove the pod from the service endpoints — so no new traffic arrives before shutdown begins.
The preStop hook duration counts against terminationGracePeriodSeconds. With a 5s preStop and 30s grace period, your app has 25s to drain. Set your setTimeout in the shutdown handler to 25s, not 30s.
Unhandled Rejection Guard
One more thing — unhandled promise rejections can crash your process without graceful shutdown:
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection at:', promise, 'reason:', reason);
// In production, you may want to exit and let the orchestrator restart
// process.exit(1);
});
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);
process.exit(1); // Always exit on uncaught exceptions
});The complete checklist for zero-dropped-requests deploys:
app.enableShutdownHooks()in NestJSserver.close()on SIGTERM to stop accepting new HTTP connectionsreadinessProbereturning 503 during shutdownpreStopsleep to drain load balancer- Queue workers closed with
worker.close()(waits for current job) terminationGracePeriodSecondsset to at least 30s
All of these together — not just one or two.
Arif Iqbal
Senior Backend Engineer with 10+ years building high-traffic platforms. NestJS · Node.js · Laravel · AWS · PostgreSQL. Open to remote & relocation.
Enjoyed this post?
Get my technical deep-dives in your inbox. No spam, unsubscribe anytime.
Discussion