Graceful Shutdown
When a Node.js process receives a termination signal — SIGTERM from Kubernetes or a process manager, SIGINT from Ctrl-C — the default behaviour is immediate exit. Any in-flight HTTP requests are dropped mid-response, open database connections are forcibly closed, background jobs are abandoned. Graceful shutdown intercepts the signal, stops accepting new requests, waits for in-flight work to finish, closes connections cleanly, then exits. It's the difference between a deploy that drops a handful of requests and one that completes them all.
What happens without graceful shutdown
SIGTERM received → process.exit() immediately ↳ In-flight requests: dropped (client gets ECONNRESET) ↳ Database transaction mid-flight: rolled back, possibly corrupt ↳ Message queue consumer: message re-queued (or lost) ↳ Log buffers: unflushed — last few log lines disappear
The graceful shutdown sequence
1. Receive SIGTERM / SIGINT 2. Stop accepting NEW connections (server.close()) 3. Wait for in-flight requests to complete (or a timeout) 4. Close DB pools, Redis clients, message consumers 5. Flush log buffers 6. process.exit(0)
Implementation with Express + http.Server
import http from 'node:http'
import app from './app.js'
import { pool } from './db/pool.js'
import { redis } from './db/redis.js'
import logger from './logger.js'
const server = http.createServer(app)
const PORT = process.env.PORT ?? 3000
server.listen(PORT, () => logger.info({ port: PORT }, 'Server started'))
async function shutdown(signal) {
logger.info({ signal }, 'Shutdown signal received — draining...')
// 1. Stop accepting new connections:
server.close(async () => {
logger.info('HTTP server closed')
// 2. Close downstream connections:
try {
await pool.end()
await redis.quit()
logger.info('DB and Redis connections closed')
process.exit(0)
} catch (err) {
logger.error({ err }, 'Error during shutdown')
process.exit(1)
}
})
// 3. Force exit if draining takes too long:
setTimeout(() => {
logger.warn('Shutdown timeout exceeded — forcing exit')
process.exit(1)
}, 10_000).unref() // .unref() so the timer doesn't prevent shutdown itself
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))The shutdown timeout and `unref()`
Kubernetes and the two-signal dance
Kubernetes rolling deploy: 1. Pod marked "Terminating" — removed from Service endpoints (no new traffic) 2. Pod receives SIGTERM (after optional preStop hook delay) 3. Your shutdown() runs — drains in-flight requests 4. After terminationGracePeriodSeconds (default 30s), SIGKILL if still alive 5. Pod exits Best practice: handle SIGTERM → drain → exit before SIGKILL deadline. Set terminationGracePeriodSeconds > your shutdown timeout.
What else to close
Resource | How to close |
|---|---|
pg Pool |
|
Prisma |
|
Redis (node-redis) |
|
Redis (ioredis) |
|
Mongoose |
|
BullMQ Worker |
|
Custom intervals / crons |
|
Full process-lifecycle wiring
// Register all process-level handlers in one place at startup:
// Fatal errors → log and exit:
process.on('uncaughtException', (err) => { logger.fatal({ err }, 'Uncaught exception'); process.exit(1) })
process.on('unhandledRejection', (reason) => { logger.fatal({ reason }, 'Unhandled rejection'); process.exit(1) })
// Signals → graceful drain:
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))Checklist
Handle
SIGTERMandSIGINT— the two signals you'll reliably receive.server.close()before closing downstream connections.Set a shutdown timeout — force exit if draining hangs.
Close DB pools, cache clients, queue workers in the shutdown callback.
Exit with code 0 on clean shutdown; code 1 on error or timeout.
Kubernetes: add a
preStopsleep to avoid the race between endpoint removal and SIGTERM.