All posts
19 Aug 2026

Graceful Shutdowns in Node.js: Draining Connections and Finishing Jobs Before Exit

{"title": "Graceful Shutdowns in Node.

{“title”: “Graceful Shutdowns in Node.js: Draining Connections and Finishing Jobs Before Exit”, “summary”: “A code-heavy, practical guide to handling SIGTERM signals, draining HTTP server connections, stopping Redis/BullMQ workers cleanly, and orchestrating timeouts in Kubernetes.”, “tags”: [“Node.js”, “Backend”, “DevOps”, “Software Architecture”, “Docker”], “body”: “# Graceful Shutdowns in Node.js: Draining Connections and Finishing Jobs Before Exit\n\nWhen deploying a new version of a Node.js microservice in a containerized environment like Kubernetes or Docker, your application does not simply pause and resume. It receives a termination signal (SIGTERM), and the infrastructure expects it to clean up its affairs and exit promptly. \n\nIf your Node.js application is unprepared, an abrupt exit results in:\n* Dropped client connections in the middle of a request.\n* Corrupted data because a database write was cut short.\n* Orphaned background jobs stuck in limbo, requiring manual intervention.\n\nIn this guide, we will build a robust production-grade shutdown sequence for a Node.js application that handles HTTP traffic (via Express or Fastify) and background background workers (via BullMQ and Redis).

Understanding the Lifecycle: SIGTERM vs. SIGINT

When your orchestration tool decides to terminate your container, it issues a SIGTERM (Signal Termination) signal. This is a polite request to shut down. If your application doesn’t exit within a designated timeout window (typically 30 seconds in Kubernetes), the orchestrator follows up with a SIGKILL, which immediately halts the process without warning.

Locally, when you press Ctrl+C in your terminal, Node.js receives a SIGINT (Signal Interrupt). We want to handle both signals identically.

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

Step 1: Draining the HTTP Server

The first rule of a graceful shutdown is to stop accepting new requests immediately while continuing to process existing inflight requests.

If you use Express, Fastify, or raw Node.js http.Server, calling .close() stops the server from listening for new socket connections. However, the server will not emit the close event until all active requests have finished and their sockets have closed.

Here is how to implement HTTP connection draining cleanly:

const express = require('express');
const app = express();

app.get('/heavy-computation', async (req, res) => {
  // Simulate long-running request
  await new Promise(resolve => setTimeout(resolve, 5000));
  res.json({ status: 'success' });
});

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

// Keep track of active connections if doing raw TCP, 
// but http.Server.close() handles HTTP-level pipelining well.

To close this server gracefully, we wrap the .close() method in a Promise:

const closeServer = () => {
  return new Promise((resolve, reject) => {
    console.log('Closing HTTP server...');
    server.close((err) => {
      if (err) {
        console.error('Error closing HTTP server', err);
        return reject(err);
      }
      console.log('HTTP server closed successfully.');
      resolve();
    });
  });
};

Step 2: Draining Background Workers (BullMQ & Redis)

If your Node.js app processes background jobs using libraries like BullMQ backed by Redis, shutting down the HTTP server isn’t enough. If your worker process dies mid-job, the job may remain in an active state indefinitely, requiring dead-letter queue recovery.

We must close our BullMQ workers gracefully by pausing them, waiting for active jobs to finish, and then closing the Redis connection.

const { Worker } = require('bullmq');

const worker = new Worker('email-queue', async (job) => {
  console.log(`Processing job ${job.id}`);
  await doWork(job.data);
}, { connection: { host: 'localhost', port: 6379 } });

const closeWorkers = async () => {
  console.log('Closing background workers...');
  // Gracefully close worker: stops picking new jobs and finishes current active jobs
  await worker.close();
  console.log('Background workers closed.');
};

Calling worker.close() tells BullMQ to stop fetching new jobs from Redis and wait for any currently executing job handler to resolve before resolving the promise.

Step 3: Closing Database and Cache Connections

Once your HTTP requests and background jobs have finished, you must close database pools (PostgreSQL, MongoDB, Prisma, Mongoose) and caching clients (Redis) to prevent memory leaks and dangling socket handles.

const mongoose = require('mongoose');
const Redis = require('ioredis');

const redisClient = new Redis();

const closeDatabases = async () => {
  console.log('Closing database and cache connections...');
  
  await Promise.all([
    mongoose.connection.close(false),
    redisClient.quit()
  ]);
  
  console.log('All database connections closed.');
};

Step 4: Orchestrating the Complete Shutdown Sequence

Now, let’s assemble all pieces into a centralized, unified gracefulShutdown function. We also need a hard timeout mechanism to ensure that if a stuck database query or hung request prevents shutdown, the process forces an exit rather than blocking the deployment pipeline indefinitely.

let isShuttingDown = false;

const gracefulShutdown = async (signal) => {
  if (isShuttingDown) {
    console.warn(`Shutdown already in progress. Received ${signal}. Ignoring...`);
    return;
  }
  isShuttingDown = true;
  console.log(`\nReceived ${signal}. Starting graceful shutdown...`);

  // 1. Forceful exit fallback timer (e.g., 25 seconds)
  const forceShutdownTimer = setTimeout(() => {
    console.error('Could not close connections in time, forcefully shutting down');
    process.exit(1);
  }, 25000);

  // Prevent Node from keeping the event loop alive just for the timer
  forceShutdownTimer.unref();

  try {
    // 2. Stop accepting new HTTP traffic
    await closeServer();

    // 3. Finish processing background jobs
    await closeWorkers();

    // 4. Close database and external clients
    await closeDatabases();

    console.log('Graceful shutdown completed successfully. Exiting process.');
    process.exit(0);
  } catch (error) {
    console.error('Error during graceful shutdown:', error);
    process.exit(1);
  }
};

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

Step 5: Handling Uncaught Exceptions and Unhandled Rejections

While graceful shutdowns are usually triggered by deployment signals, unexpected application faults can also corrupt data. While you should never attempt a full, long-running graceful shutdown on an uncaughtException (since your application state is already compromised), you must give active logs and transports time to flush.

process.on('uncaughtException', (error) => {
  console.error('Uncaught Exception thrown:', error);
  // Give Winston/Pino logger 1 second to flush logs to disk/stdout
  setTimeout(() => {
    process.exit(1);
  }, 1000);
});

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // Track and handle appropriately, or trigger graceful shutdown
  gracefulShutdown('unhandledRejection');
});

Warning: Never try to cleanly close database pools inside an uncaughtException handler if the error originated from memory corruption or a deep state failure. Flush your loggers and exit immediately with status 1.

Docker and Kubernetes Configuration Considerations

Writing code in Node.js is only half the battle. Your container runtime must be configured properly to ensure signals reach your Node.js process.

1. The PID 1 Problem in Docker

By default, Docker runs your command as a child process of a shell (e.g., /bin/sh -c node server.js), meaning PID 1 is the shell, not Node. Shells often ignore or fail to forward SIGTERM signals down to child processes.

Solution: Always use the exec form in your Dockerfile or docker-compose.yml:

# Correct: Node runs as PID 1 and receives SIGTERM directly
CMD ["node", "server.js"]

Alternatively, use an init system like tini to forward signals properly.

2. Kubernetes Readiness and Liveness Probes

When Kubernetes triggers a rolling update, it performs two actions simultaneously:

  1. It sends a SIGTERM to your old pods.
  2. It removes the old pods from the Kubernetes Service endpoints so new traffic stops routing to them.

However, there is often a race condition: Endpoints might not update instantly before SIGTERM arrives.

To mitigate dropped connections during this race condition, implement a short delay (e.g., 5 seconds) at the very beginning of your SIGTERM handler before calling server.close(), or utilize a pre-stop lifecycle hook in your Kubernetes deployment manifest:

lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 5"]

This ensures that the load balancer has finished routing traffic away from the pod before your Node.js application stops accepting connections.

Conclusion

Implementing a robust graceful shutdown procedure transforms your Node.js services from fragile scripts into resilient production-grade infrastructure components. By systematically draining HTTP sockets, completing active job queues, cleanly dropping database connections, and coordinating timeouts with your container orchestrator, you ensure zero downtime deployments and uncorrupted data integrity.

More posts