Graceful Shutdown

Difficulty: Advanced

Question

How do you implement graceful shutdown in a Node.js application? Why is it important for production?

Answer

Graceful shutdown ensures that when a Node.js process receives a termination signal, it finishes processing current requests, closes database connections, flushes logs, and exits cleanly instead of abruptly killing everything mid-request.

Without graceful shutdown, terminating a server can corrupt data (mid-write database operations), lose responses (clients get connection reset errors), leak resources (open file handles, DB connections), and leave distributed transactions in an inconsistent state.

The process listens for SIGTERM (from Docker/Kubernetes stop) and SIGINT (from Ctrl+C). On signal, it stops accepting new connections via server.close(), waits for in-flight requests to complete, closes database pools and Redis connections, and finally exits.

In container environments (Docker, Kubernetes), graceful shutdown is critical. Kubernetes sends SIGTERM and waits a configurable period (default 30s) before forcefully killing the pod. Your app must clean up within this window. Health check endpoints should return unhealthy immediately on shutdown to stop receiving new traffic.

Code examples

Complete Graceful Shutdown Implementation

const express = require('express');
const { PrismaClient } = require('@prisma/client');

const app = express();
const prisma = new PrismaClient();
let isShuttingDown = false;

// 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() });
});

// Reject new requests during shutdown
app.use((req, res, next) => {
  if (isShuttingDown) {
    res.set('Connection', 'close');
    return res.status(503).json({ error: 'Server is shutting down' });
  }
  next();
});

const server = app.listen(3000, () => {
  console.log('Server started on port 3000');
});

async function gracefulShutdown(signal) {
  console.log(`${signal} received. Starting graceful shutdown...`);
  isShuttingDown = true;

  // 1. Stop accepting new connections
  server.close(async () => {
    console.log('HTTP server closed');

    // 2. Close database connections
    await prisma.$disconnect();
    console.log('Database disconnected');

    // 3. Close Redis, message queues, etc.
    // await redis.quit();

    console.log('Graceful shutdown complete');
    process.exit(0);
  });

  // Force shutdown after timeout
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 30000); // 30 second timeout
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

server.close() stops new connections but lets existing ones finish. The forced timeout is a safety net if cleanup takes too long.

Docker and Kubernetes Considerations

// Dockerfile - handle signals properly
// CMD ["node", "dist/index.js"]  // GOOD: node gets signals directly
// CMD node dist/index.js          // BAD: runs in shell, signals don't reach node

// Kubernetes deployment readiness probe
// spec:
//   containers:
//     readinessProbe:
//       httpGet:
//         path: /health
//         port: 3000
//       periodSeconds: 5
//     lifecycle:
//       preStop:
//         exec:
//           command: ["sh", "-c", "sleep 5"]  # Allow LB to drain

// Kubernetes terminationGracePeriodSeconds: 60

// Node.js: handle container-specific patterns
const server = app.listen(3000);

// Keep-alive connections need to be tracked
const connections = new Set();
server.on('connection', (conn) => {
  connections.add(conn);
  conn.on('close', () => connections.delete(conn));
});

async function gracefulShutdown() {
  isShuttingDown = true;

  // Set a short keep-alive timeout to close idle connections
  server.keepAliveTimeout = 1;
  server.headersTimeout = 1;

  server.close(async () => {
    await prisma.$disconnect();
    process.exit(0);
  });

  // Destroy idle connections immediately
  for (const conn of connections) {
    conn.end(); // Graceful close
  }

  setTimeout(() => {
    for (const conn of connections) {
      conn.destroy(); // Force close
    }
    process.exit(1);
  }, 30000);
}

In Docker, use exec form CMD so Node receives signals. Track active connections to close idle ones. Kubernetes needs sleep in preStop to let the load balancer drain.

Cleanup with Async Resources

class Application {
  constructor() {
    this.resources = [];
  }

  // Register cleanup functions
  registerCleanup(name, fn) {
    this.resources.push({ name, cleanup: fn });
  }

  async shutdown(signal) {
    console.log(`[${signal}] Shutting down...`);

    // Run all cleanup functions in parallel with timeout
    const cleanupPromises = this.resources.map(async ({ name, cleanup }) => {
      try {
        await Promise.race([
          cleanup(),
          new Promise((_, reject) =>
            setTimeout(() => reject(new Error('timeout')), 10000)
          ),
        ]);
        console.log(`[cleanup] ${name}: done`);
      } catch (err) {
        console.error(`[cleanup] ${name}: failed - ${err.message}`);
      }
    });

    await Promise.allSettled(cleanupPromises);
    console.log('All cleanup complete');
    process.exit(0);
  }
}

// Usage
const app = new Application();
app.registerCleanup('http-server', () => new Promise(r => server.close(r)));
app.registerCleanup('database', () => prisma.$disconnect());
app.registerCleanup('redis', () => redis.quit());
app.registerCleanup('flush-logs', () => logger.flush());

process.on('SIGTERM', () => app.shutdown('SIGTERM'));
process.on('SIGINT', () => app.shutdown('SIGINT'));

A cleanup registry pattern makes it easy to add/remove cleanup tasks. Each task runs with a timeout to prevent hanging. Promise.allSettled ensures all tasks run even if some fail.

Key points

Concepts covered

SIGTERM, SIGINT, Connection Draining, Health Checks, Cleanup