NodeJSGraceful Shutdown

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

Text
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

Text
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

JS
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'))
`server.close()` stops new connections; in-flight requests keep running until the callback fires
`server.close(cb)` tells Node's HTTP server to stop accepting new connections. Existing connections (currently serving a request) are kept open; the callback fires when the last one completes. That's exactly what you want: the server is no longer reachable for new traffic, but active requests land cleanly. The shutdown timeout is a safety net — if a long-running request or a stuck connection prevents the server from closing within N seconds, force the exit rather than hanging forever.
The shutdown timeout and `unref()`
Always set a shutdown timeout — a stuck request can prevent exit forever
Without a timeout, `server.close()` could wait indefinitely if a WebSocket connection or a long-poll never terminates. Set a timeout (10–30 seconds is typical) and call `process.exit(1)` if it's reached. The `.unref()` call on the timeout prevents the timer itself from keeping the event loop alive — if everything closes cleanly before the timeout fires, the process can exit without waiting for the timer.
Kubernetes and the two-signal dance

Text
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.
Kubernetes removes the pod from the load balancer BEFORE sending SIGTERM
There's a small race: Kubernetes removes the pod from endpoints and sends SIGTERM roughly simultaneously, but existing connections in flight may still arrive for a second or two while DNS/iptables rules propagate. A common fix is a short `preStop` hook sleep (e.g. 5 seconds) that delays SIGTERM, giving the load balancer time to stop routing traffic before the process starts draining. Without this, the last few requests arriving right as shutdown starts get dropped even with graceful shutdown code in place.
What else to close

Resource

How to close

pg Pool

await pool.end()

Prisma

await prisma.$disconnect()

Redis (node-redis)

await redis.quit()

Redis (ioredis)

await redis.quit()

Mongoose

await mongoose.connection.close()

BullMQ Worker

await worker.close()

Custom intervals / crons

clearInterval(), wait for current run

Full process-lifecycle wiring

JS
// 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 SIGTERM and SIGINT — 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 preStop sleep to avoid the race between endpoint removal and SIGTERM.

Next
Protect what you've built: [Node.js Security Overview](/nodejs/security-intro).