Skip to content
Node.js nd process 4 min read

Signals & Graceful Shutdown

When a Node.js process is asked to stop, the operating system delivers a signal — a small asynchronous notification like SIGINT (Ctrl+C) or SIGTERM (the default kill). By default these signals terminate the process immediately, abandoning in-flight HTTP requests and leaving database connections half-open. A graceful shutdown intercepts the signal, stops accepting new work, drains what is already running, releases resources, and only then exits. This is essential for zero-downtime deploys and for surviving orchestrators like Kubernetes, which send SIGTERM before forcibly killing a container.

How Node receives signals

Node.js exposes signals as events on the global process object. Attaching a listener overrides the default behavior, so the process will not exit unless you call process.exit() yourself (or the event loop empties). The most common signals to handle are:

SignalSourceDefault actionCatchable
SIGINTCtrl+C in the terminalTerminateYes
SIGTERMkill <pid>, orchestrator stopTerminateYes
SIGHUPTerminal closed / reload conventionTerminateYes
SIGKILLkill -9Force killNo
SIGQUITCtrl+\Terminate + core dumpYes

SIGKILL and SIGSTOP cannot be caught, blocked, or ignored. Never rely on cleanup running for kill -9 — design your shutdown around the catchable signals and treat SIGKILL as a last resort.

A minimal listener looks like this:

process.on('SIGTERM', () => {
  console.log('Received SIGTERM, shutting down');
  process.exit(0);
});

A complete graceful shutdown

A realistic shutdown stops the HTTP server from accepting new connections, waits for active requests to finish, closes database pools, and enforces a hard timeout so a stuck connection cannot hang the process forever. The example below uses the built-in node:http module and a generic pool.

import http from 'node:http';
import { pool } from './db.js'; // e.g. a pg or mysql2 connection pool

const server = http.createServer(async (req, res) => {
  // Simulate work backed by the database
  const { rows } = await pool.query('SELECT now() AS ts');
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ ts: rows[0].ts }));
});

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

let shuttingDown = false;

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

  // Force-exit if draining takes too long (e.g. a hung keep-alive socket)
  const forceTimer = setTimeout(() => {
    console.error('Shutdown timed out, forcing exit');
    process.exit(1);
  }, 10_000);
  forceTimer.unref(); // don't let the timer keep the process alive

  try {
    // 1. Stop accepting new connections; callback fires when all are closed
    await new Promise((resolve, reject) => {
      server.close((err) => (err ? reject(err) : resolve()));
    });
    console.log('HTTP server closed');

    // 2. Release downstream resources
    await pool.end();
    console.log('Database pool drained');

    clearTimeout(forceTimer);
    process.exit(0);
  } catch (err) {
    console.error('Error during shutdown', err);
    process.exit(1);
  }
}

for (const signal of ['SIGINT', 'SIGTERM']) {
  process.on(signal, () => shutdown(signal));
}

Output:

Listening on http://localhost:3000
^C
SIGINT received — starting graceful shutdown
HTTP server closed
Database pool drained

server.close() stops new connections but its callback only fires once every existing request has completed, which is exactly the draining behavior you want.

Draining keep-alive connections

server.close() waits for active requests, but idle HTTP keep-alive sockets can keep the callback from firing. In Node 18.2+ you can close those idle sockets explicitly so draining finishes promptly while still letting in-flight requests complete.

server.closeIdleConnections(); // drop sockets with no active request
// If you must be aggressive after the timeout:
// server.closeAllConnections();

Set a sensible keep-alive timeout too, so the server naturally sheds idle sockets:

server.keepAliveTimeout = 5_000;
server.headersTimeout = 6_000; // must exceed keepAliveTimeout

Why this matters in containers

Container orchestrators expect processes to cooperate with shutdown. When Kubernetes terminates a pod it sends SIGTERM, waits for terminationGracePeriodSeconds (30s by default), and only then sends SIGKILL. During a rolling deploy the pod is simultaneously removed from the load balancer, so a graceful drain lets the last few requests finish on the old pod while traffic shifts to the new one — no dropped requests, no 502s.

Two common pitfalls in containers:

  • PID 1 and signal handling. If Node runs as PID 1 (started directly by CMD ["node", "server.js"]), it receives signals but does not reap zombie child processes. Prefer the exec form of CMD so Node actually gets the signal, and add an init like --init (docker run --init) if you spawn children.
  • Shell wrapping swallows signals. CMD npm start runs Node under a shell that may not forward SIGTERM. Run Node directly, or ensure your process manager forwards signals.

Keep your shutdown timeout shorter than the orchestrator’s grace period (e.g. 10s app timeout vs. 30s grace period). Otherwise the orchestrator sends SIGKILL mid-cleanup and your drain is wasted.

Best Practices

  • Handle both SIGINT and SIGTERM with the same handler so local Ctrl+C and production stops behave identically.
  • Guard against repeated signals with a shuttingDown flag — a second Ctrl+C should not start a second cleanup pass.
  • Always close the HTTP server before closing database and cache connections, so in-flight requests still have what they need.
  • Enforce a hard timeout with setTimeout(...).unref() and force-exit; never trust draining to always complete.
  • Exit with code 0 on a clean shutdown and a non-zero code on timeout or error, so orchestrators can distinguish them.
  • Keep your app’s shutdown budget below the orchestrator’s grace period, and tune keepAliveTimeout so idle sockets drain on their own.
  • Also flush logs, metrics, and message-queue consumers during shutdown — anything buffered in memory should be persisted before exit.
Last updated June 14, 2026
Was this helpful?