Skip to content
Express.js ex deployment 4 min read

Graceful Shutdown

When an orchestrator like Kubernetes, a process manager like PM2, or a plain docker stop decides to replace your Express process, it sends a signal and gives you a short grace period before forcibly killing it. A graceful shutdown uses that window to stop accepting new connections, let in-flight requests finish, and close database pools and other resources. Skipping this step means dropped requests, half-written database rows, and leaked connections during every deploy — exactly the kind of papercut that erodes a “zero downtime” rollout.

Which signals to listen for

Process managers signal intent to stop with POSIX signals. The two that matter for web servers are SIGTERM (the polite “please stop” that Kubernetes and docker stop send) and SIGINT (what you get from Ctrl+C in a terminal). You should treat both the same way.

SignalSent byDefault actionCatchable
SIGTERMKubernetes, docker stop, PM2TerminateYes
SIGINTCtrl+C in terminalTerminateYes
SIGKILLkill -9, OOM killerForce killNo — cannot be handled

SIGKILL (and SIGSTOP) can never be intercepted. The whole point of graceful shutdown is to finish your work before the orchestrator escalates from SIGTERM to SIGKILL, which Kubernetes does after terminationGracePeriodSeconds (30s by default).

Draining requests with server.close

The core primitive is server.close(). It stops the server from accepting new connections immediately, but keeps existing connections open until their in-flight requests complete. The callback fires once every active connection has finished — that is your signal that it’s safe to close downstream resources and exit.

Capture the value returned by app.listen(); that is the http.Server instance you call close() on.

import express from 'express';

const app = express();

app.get('/health', (req, res) => res.json({ status: 'ok' }));

app.get('/work', async (req, res) => {
  // Simulate a slow downstream call
  await new Promise((resolve) => setTimeout(resolve, 3000));
  res.json({ done: true });
});

const server = app.listen(3000, () => {
  console.log('Listening on http://localhost:3000');
});

Closing the database and other resources

Once server.close() has drained HTTP traffic, tear down anything that holds open sockets or file handles: database pools, Redis clients, message-queue consumers. Do this after the drain so in-flight handlers can still query the database while they finish.

import express from 'express';
import { Pool } from 'pg';

const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

app.get('/users/:id', async (req, res, next) => {
  try {
    const { rows } = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
    res.json(rows[0] ?? null);
  } catch (err) {
    next(err);
  }
});

const server = app.listen(3000, () => console.log('Listening on :3000'));

let shuttingDown = false;

async function shutdown(signal) {
  if (shuttingDown) return; // ignore repeated signals
  shuttingDown = true;
  console.log(`${signal} received: starting graceful shutdown`);

  // 1. Stop accepting new connections, drain existing ones.
  server.close(async (err) => {
    if (err) {
      console.error('Error during server close', err);
      process.exit(1);
    }
    // 2. Close downstream resources.
    await pool.end();
    console.log('Drained requests and closed DB pool. Bye.');
    process.exit(0);
  });
}

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

Output:

Listening on :3000
SIGTERM received: starting graceful shutdown
Drained requests and closed DB pool. Bye.

Forcing a timeout for stuck connections

server.close() waits forever if a client keeps a connection open (think a hung keep-alive socket or a slow streaming response). To guarantee the process exits within the grace period, arm a timer that force-exits if draining takes too long. Call .unref() on the timer so it never keeps the event loop alive on its own.

const SHUTDOWN_TIMEOUT_MS = 10_000;

async function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(`${signal} received: draining (max ${SHUTDOWN_TIMEOUT_MS}ms)`);

  const forceExit = setTimeout(() => {
    console.error('Drain timed out — forcing exit');
    process.exit(1);
  }, SHUTDOWN_TIMEOUT_MS).unref();

  server.close(async () => {
    clearTimeout(forceExit);
    await pool.end();
    process.exit(0);
  });
}

In Node 18.2+ you can also close idle keep-alive sockets immediately with server.closeIdleConnections(), and forcibly destroy all sockets with server.closeAllConnections() — useful if you want a hard cutoff after a shorter window.

Always keep your shutdown timeout shorter than the orchestrator’s grace period. If Kubernetes uses the default terminationGracePeriodSeconds: 30, a 10s app timeout leaves headroom for the platform to clean up before it escalates to SIGKILL.

Express 5 and a readiness note

Express 5 runs on the same underlying http.Server, so the server.close() pattern is identical; the only behavioral change is that async route handlers can reject and Express 5 forwards the error to your error middleware automatically. For zero-downtime rollouts, also flip your /health (readiness) endpoint to return 503 as soon as shutdown begins, so the load balancer stops routing new traffic to the pod while existing requests drain.

app.get('/health', (req, res) => {
  res.status(shuttingDown ? 503 : 200).json({ status: shuttingDown ? 'shutting_down' : 'ok' });
});

Best Practices

  • Listen for both SIGTERM and SIGINT, and guard against repeated signals with a shuttingDown flag.
  • Drain HTTP first with server.close(), then close DB pools, Redis, and queue consumers in the callback.
  • Always arm an .unref()’d timeout so a stuck socket can’t block exit past the orchestrator’s grace period.
  • Keep the app shutdown timeout shorter than terminationGracePeriodSeconds (or your platform’s equivalent).
  • Flip readiness probes to 503 on shutdown so traffic stops arriving before connections are forced closed.
  • Exit with code 0 on a clean drain and a non-zero code on timeout or error, so supervisors can distinguish them.
  • Use server.closeIdleConnections() to release idle keep-alive sockets quickly without dropping active requests.
Last updated June 14, 2026
Was this helpful?