Node.js applications often die abruptly when they receive a SIGTERM signal, losing in-flight requests and leaving resources dangling.

Let’s see a basic HTTP server that doesn’t handle SIGTERM gracefully:

const http = require('http');

const server = http.createServer((req, res) => {
  // Simulate a long-running request
  setTimeout(() => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World!\n');
  }, 5000);
});

const PORT = 3000;
server.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

process.on('SIGTERM', () => {
  console.log('Received SIGTERM. Shutting down...');
  // No explicit shutdown logic here
});

process.on('SIGINT', () => {
  console.log('Received SIGINT. Shutting down...');
  // No explicit shutdown logic here
});

If you run this, then try to kill it with kill <pid> (which sends SIGTERM), you’ll see "Received SIGTERM. Shutting down…" but the server will likely terminate before the 5-second setTimeout completes. Any requests that were in their 5-second processing window will be unceremoniously dropped.

The core problem is that Node.js, by default, doesn’t automatically stop accepting new connections or wait for existing ones to finish when it gets a SIGTERM or SIGINT. It just exits. To handle this gracefully, we need to explicitly tell the server to stop accepting new connections and then wait for all active connections to complete before exiting the process.

Here’s how to implement proper graceful shutdown:

1. Stop Accepting New Connections

The first step is to tell the underlying HTTP server to stop listening for new incoming requests. The server.close() method does exactly this. It stops the server from accepting any new connections, but it doesn’t close any existing connections that are currently in progress.

// ... inside your SIGTERM handler ...
server.close(() => {
  console.log('HTTP server closed. No new connections accepted.');
  // Now proceed to close other resources and exit
});

2. Wait for Existing Connections

After calling server.close(), there might still be active requests being processed. We need to wait for these to finish. Node.js keeps track of active connections. You can monitor the number of active connections using server._connections. A more robust way is to use a counter that increments when a connection is established and decrements when it’s closed.

let isShuttingDown = false;
let activeConnections = 0;

server.on('connection', (socket) => {
  activeConnections++;
  socket.on('close', () => {
    activeConnections--;
    if (isShuttingDown && activeConnections === 0) {
      console.log('All active connections closed. Shutting down.');
      process.exit(0); // Exit successfully
    }
  });
});

process.on('SIGTERM', () => {
  console.log('Received SIGTERM. Initiating graceful shutdown...');
  isShuttingDown = true;

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

    // 2. Check if there are any active connections left
    if (activeConnections === 0) {
      console.log('No active connections. Shutting down.');
      process.exit(0); // Exit successfully
    }
    // If there are active connections, the 'close' event on sockets will handle the exit
  });

  // Optional: Force exit after a timeout if graceful shutdown takes too long
  setTimeout(() => {
    console.error('Graceful shutdown timed out. Forcing exit.');
    process.exit(1); // Exit with an error code
  }, 10000); // 10 seconds timeout
});

When SIGTERM is received, we set isShuttingDown to true. Then, server.close() is called. The connection event listener increments activeConnections for each new socket. Crucially, when a socket closes (meaning a request has finished or been aborted), activeConnections is decremented. If isShuttingDown is true and activeConnections drops to zero, we know all requests are done, and we can safely exit.

3. Clean Up Other Resources

Your application might have other resources that need closing: database connections, file handles, timers, message queue consumers, etc. These should all be closed before the process exits.

// Example: Database connection pool
const dbPool = require('./db'); // Assume this is your DB pool

// ... inside SIGTERM handler, after server.close() and connection checks ...

// Close database connections
dbPool.end((err) => {
  if (err) {
    console.error('Error closing database pool:', err);
    process.exit(1); // Exit with error if DB close fails
  }
  console.log('Database pool closed.');
  // Proceed to exit or close other resources
});

4. Handle SIGINT Similarly

SIGINT (typically sent by Ctrl+C) is another signal that usually means "stop the process." It’s good practice to handle it the same way as SIGTERM for consistency.

process.on('SIGINT', () => {
  console.log('Received SIGINT. Initiating graceful shutdown...');
  // Reuse the SIGTERM handler logic or call a common function
  handleGracefulShutdown();
});

function handleGracefulShutdown() {
  isShuttingDown = true;
  server.close(() => {
    console.log('HTTP server closed.');
    if (activeConnections === 0) {
      console.log('All connections closed. Shutting down.');
      process.exit(0);
    }
  });

  setTimeout(() => {
    console.error('Shutdown timed out. Forcing exit.');
    process.exit(1);
  }, 10000);
}

5. Consider a Timeout

What if a request hangs indefinitely, or a cleanup operation gets stuck? A graceful shutdown could theoretically run forever. It’s wise to implement a timeout. If the shutdown process doesn’t complete within a reasonable time (e.g., 10-30 seconds), force the exit. This prevents your application from appearing to hang.

The example above includes a setTimeout within the SIGTERM handler to force an exit after 10 seconds if activeConnections hasn’t reached zero.

The "Why" of the Socket Close Event

The reason we attach logic to the socket.on('close', ...) event is that it fires not just when a client finishes a request, but also when a connection is terminated prematurely (e.g., client disconnects, network issue, or the server itself is shutting down and explicitly closing the socket). By decrementing activeConnections here, we ensure that the shutdown process correctly accounts for all connection states, including those that might have been aborted. When isShuttingDown is true and activeConnections hits zero, it signifies that all requests that were in flight when server.close() was called have now completed or been terminated, and no new ones are being accepted.

The next thing you’ll likely encounter is managing shutdown for asynchronous operations beyond HTTP requests, such as worker threads or external service clients.

Want structured learning?

Take the full Nodejs course →