Express servers can be shut down gracefully without dropping requests by properly handling SIGTERM and SIGINT signals, closing existing connections, and stopping new ones from being accepted.

Here’s a common setup for graceful shutdown:

const express = require('express');
const http = require('http');
const app = express();
const server = http.createServer(app);
const port = 3000;

// Your Express routes and middleware here
app.get('/', (req, res) => {
  // Simulate a long-running request
  setTimeout(() => {
    res.send('Hello from Express!');
  }, 5000);
});

// Graceful shutdown logic
const shutdown = () => {
  console.log('Received kill signal, shutting down gracefully...');
  server.close(() => {
    console.log('Http server closed.');
    // Close any database connections or other resources here
    process.exit(0);
  });

  // Force close server after 10 seconds if it doesn't close gracefully
  setTimeout(() => {
    console.error('Could not close connections in time, forcefully shutting down');
    process.exit(1);
  }, 10000);
};

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

server.listen(port, () => {
  console.log(`Server listening on port ${port}`);
});

When you run this server and send a SIGTERM (e.g., kill <pid>) or SIGINT (Ctrl+C) signal, the shutdown function is invoked. It first calls server.close(), which stops accepting new connections but allows existing ones to complete. The callback for server.close() then handles any final cleanup (like closing database connections) before process.exit(0) terminates the process cleanly. A timeout is included to prevent the server from hanging indefinitely if connections don’t close within a reasonable time.

Consider a scenario where a client makes a request just as the shutdown signal is sent. The server.close() call prevents new requests from being accepted, but the existing request, which might be in the middle of processing (like the setTimeout in the example), will continue to completion. Once it finishes, the connection is closed, and the server eventually shuts down.

The key to this mechanism is the http.Server object’s close() method. Unlike process.exit(), which abruptly terminates the Node.js process, server.close() signals the underlying HTTP server to stop listening for new connections. It then waits for all active connections to close. This is crucial for preventing data loss or a poor user experience by ensuring that ongoing requests are not interrupted mid-flight.

The SIGTERM signal is the standard way to request a process to terminate, often sent by process managers like PM2 or Docker. SIGINT is typically generated when you press Ctrl+C in your terminal. By handling both, you make your application robust to common shutdown procedures.

Within the server.close() callback, you can perform additional cleanup. This is where you’d typically close database connections, release locks, or stop background workers. For example, if you’re using Mongoose for MongoDB, you might add mongoose.connection.close() before process.exit(0).

The timeout mechanism is a safety net. While server.close() aims for a clean shutdown, a stuck connection or a very long-running request could theoretically cause the server to never fully close. The setTimeout ensures that even in these edge cases, the application will eventually terminate, preventing it from becoming a zombie process. The exit code of 1 signifies an abnormal termination.

The http.Server object in Node.js manages the underlying socket connections. When server.close() is called, it enters a "graceful shutdown" state. It stops accepting new incoming connections on its listening port. However, it does not immediately close any connections that are already established. These active connections are allowed to complete their current request-response cycles. Once a connection is no longer active (i.e., the response has been sent and the client has potentially disconnected), the server will eventually close that connection as well.

The process.on('exit', ...) event listener is not suitable for performing asynchronous cleanup operations like closing a server. This is because the exit event handler is called after Node.js has already begun the process of exiting, and asynchronous operations started within it may not complete. You must use process.on('SIGTERM', ...) and process.on('SIGINT', ...) to initiate the graceful shutdown before the process is about to exit.

The most surprising aspect of graceful shutdown is how the server.close() method interacts with the event loop. It doesn’t just kill connections; it signals the underlying net.Server to stop accepting new connections and then waits for the existing active connections to become idle before it closes the server socket. This means that even if you call server.close() immediately, a request that is currently being processed will still complete.

The next challenge is managing concurrent connections and ensuring your cleanup logic is robust for all possible connection states.

Want structured learning?

Take the full Express course →