0Pricing
Node.js Backend Development Bootcamp · Lesson

Graceful Shutdown and In-Flight Request Draining

Handle SIGTERM to drain connections, finish work, and close resources cleanly during deploys.

Graceful Shutdown and In-Flight Request Draining is a free Node.js Backend Development Bootcamp lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Node.js Backend Development Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why Graceful Shutdown Matters

During a deploy, your orchestrator (Kubernetes, ECS, systemd) sends your process a signal and gives it a short window before forcibly killing it. If you exit immediately, you drop in-flight requests, abort half-written database transactions, and return broken responses to users.

A graceful shutdown means:

  • Stop accepting new connections.
  • Let in-flight requests finish (drain).
  • Close resources cleanly: DB pools, message brokers, timers.
  • Exit with code 0 before the kill timer fires.

This lesson builds a production-grade shutdown path step by step.

The Signals: SIGTERM vs SIGINT vs SIGKILL

Process managers communicate shutdown intent via POSIX signals. You must handle the right ones:

  • SIGTERM — the polite "please stop" sent by Kubernetes, Docker, and systemd during a rollout. This is the one you handle.
  • SIGINT — sent by Ctrl+C in a terminal. Handle it too for local dev parity.
  • SIGKILL (signal 9) — cannot be caught, blocked, or ignored. The OS terminates you instantly. This is what fires if you miss the grace window.

Node lets you register listeners for catchable signals on process.

process.on('SIGTERM', () => {
  console.log('Received SIGTERM, starting graceful shutdown');
  shutdown();
});

process.on('SIGINT', () => {
  console.log('Received SIGINT, starting graceful shutdown');
  shutdown();
});

function shutdown() {
  // close servers, drain requests, release resources
}

server.close() Drains, It Does Not Kill

The core primitive is server.close([callback]) on an http.Server. Its behavior is exactly what draining requires:

  • It stops the server from accepting new connections immediately.
  • It keeps existing connections alive until their in-flight requests complete.
  • The callback fires only once all connections are closed.

The trap: with HTTP keep-alive, idle connections stay open, so close()'s callback can hang. We solve that shortly. First, the happy path with a complete standalone server.

const http = require('http');

const server = http.createServer((req, res) => {
  // simulate a slow in-flight request
  setTimeout(() => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('done\n');
  }, 500);
});

server.listen(3000, () => console.log('listening on 3000'));

process.on('SIGTERM', () => {
  console.log('SIGTERM: closing server, draining requests');
  server.close(() => {
    console.log('all connections drained, exiting');
    process.exit(0);
  });
});

The Hard Deadline: Never Drain Forever

Your orchestrator will SIGKILL you when its terminationGracePeriodSeconds elapses (default 30s in Kubernetes). A misbehaving client holding a connection open could otherwise make server.close() hang past that window, turning a clean shutdown into a hard kill.

The rule: always pair draining with a hard timeout that is shorter than the orchestrator's grace period. If draining wins, you exit 0. If the timeout wins, you exit non-zero so the failure is visible.

Use timer.unref() so the timeout itself never keeps the event loop alive.

function gracefulShutdown(server, { timeoutMs = 25000 } = {}) {
  let shuttingDown = false;
  return () => {
    if (shuttingDown) return;
    shuttingDown = true;
    console.log('draining...');

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

    server.close((err) => {
      clearTimeout(forceExit);
      if (err) { console.error(err); process.exit(1); }
      console.log('drained cleanly');
      process.exit(0);
    });
  };
}

Idempotent Shutdown: Guard Against Double Signals

Signals can arrive more than once: an operator hits Ctrl+C twice, or both SIGTERM and SIGINT fire. If shutdown runs twice you'll call server.close() on an already-closing server and may double-close pools, throwing errors mid-shutdown.

Make the handler idempotent with a boolean guard (shown in the previous scene as shuttingDown). The first signal starts the drain; subsequent signals are ignored. Here is a tiny, fully standalone demonstration of the guard pattern you can run directly.

let shuttingDown = false;

function shutdown(reason) {
  if (shuttingDown) {
    console.log('already shutting down, ignoring ' + reason);
    return;
  }
  shuttingDown = true;
  console.log('shutdown started by ' + reason);
}

// simulate two rapid signals
shutdown('SIGTERM');
shutdown('SIGINT');
shutdown('SIGTERM');
console.log('handler ran exactly once');

Stop the Load Balancer From Sending New Traffic

There is a race during rollouts: the orchestrator sends SIGTERM at roughly the same time it removes your pod from the load balancer's endpoint list. For a brief moment the LB may still route new requests to a pod that is already draining.

The common fix is a readiness probe that flips to "not ready" the instant shutdown begins. The LB stops sending new traffic before server.close() rejects connections, avoiding spurious 502s.

Flip a flag, let the probe fail, then begin draining (optionally after a short delay so the LB observes the change).

let isShuttingDown = false;

// readiness endpoint the orchestrator polls
app.get('/readyz', (req, res) => {
  if (isShuttingDown) {
    return res.status(503).send('shutting down');
  }
  res.status(200).send('ok');
});

process.on('SIGTERM', () => {
  isShuttingDown = true; // probe now fails -> LB stops new traffic
  setTimeout(beginDrain, 5000); // give the LB a few probe cycles
});

Taming HTTP Keep-Alive Connections

HTTP keep-alive keeps TCP connections open between requests. During shutdown an idle keep-alive connection has no in-flight request, yet it prevents server.close()'s callback from firing because the socket is still open.

Two robust options:

  • Send Connection: close on responses once shutdown begins, so clients stop reusing the socket.
  • Set server.keepAliveTimeout and headersTimeout sensibly, and track sockets to destroy idle ones during drain.

Tracking sockets gives you precise control: finish active requests, but forcibly end idle ones.

const http = require('http');
const connections = new Set();

const server = http.createServer((req, res) => {
  if (isShuttingDown) res.setHeader('Connection', 'close');
  res.end('ok\n');
});

server.on('connection', (socket) => {
  connections.add(socket);
  socket.on('close', () => connections.delete(socket));
});

function destroyIdleConnections() {
  for (const socket of connections) {
    if (socket._isIdle) socket.destroy();
  }
}

Marking Sockets Idle vs Active

To safely destroy only idle sockets, mark a socket active when a request starts and idle when its response finishes. During drain you destroy the ones still idle; active ones are left alone to complete.

This is precisely the strategy battle-tested libraries like stoppable and http-terminator implement for you. Understanding the mechanism lets you debug it when it misbehaves.

const connections = new Set();

server.on('connection', (socket) => {
  socket._isIdle = true;
  connections.add(socket);
  socket.on('close', () => connections.delete(socket));
});

server.on('request', (req, res) => {
  req.socket._isIdle = false;          // active: a request is in flight
  res.on('finish', () => {
    req.socket._isIdle = true;         // response sent: back to idle
    if (isShuttingDown) req.socket.destroy(); // no reuse during drain
  });
});

function closeIdle() {
  for (const s of connections) if (s._isIdle) s.destroy();
}

Closing Downstream Resources in Order

After the HTTP server has drained, release everything else. Order matters: close the HTTP server first so no new work arrives, then drain queues/consumers, then close the database pool last (in-flight handlers may still need it while finishing).

A typical teardown sequence:

  • server.close() — stop accepting requests, drain HTTP.
  • Stop message consumers (Kafka/RabbitMQ/BullMQ) so no new jobs start.
  • Wait for in-flight jobs, then close broker connections.
  • await pool.end() — drain and close the DB connection pool.
  • Flush logs/metrics, then process.exit(0).
async function shutdown(server, pool, broker) {
  await new Promise((resolve, reject) =>
    server.close((err) => (err ? reject(err) : resolve()))
  );
  console.log('http drained');

  await broker.stopConsuming();   // no new jobs
  await broker.drainInFlight();   // finish started jobs
  await broker.close();
  console.log('broker closed');

  await pool.end();               // close DB pool last
  console.log('db pool closed');
}

A Promisified, Timed Shutdown Orchestrator

Put it together as one reusable function. It races the full async teardown against a hard deadline using Promise.race. Whoever finishes first decides the exit code. This pattern is framework-agnostic and works with Express, Fastify, or a raw http.Server.

Key properties: idempotent via the guard, bounded by timeoutMs, and explicit about success (exit 0) versus forced exit (exit 1).

function installGracefulShutdown(teardown, { timeoutMs = 25000 } = {}) {
  let started = false;
  const run = async (signal) => {
    if (started) return;
    started = true;
    console.log('shutdown via ' + signal);

    const deadline = new Promise((_, reject) => {
      const t = setTimeout(() => reject(new Error('timeout')), timeoutMs);
      t.unref();
    });

    try {
      await Promise.race([teardown(), deadline]);
      console.log('clean shutdown');
      process.exit(0);
    } catch (err) {
      console.error('forced shutdown:', err.message);
      process.exit(1);
    }
  };

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

Don't Forget Crashes: uncaughtException

Graceful shutdown handles deliberate termination. Fatal errors are different. An uncaughtException or unhandledRejection leaves the process in an undefined state; the safe response is to log, attempt a best-effort resource flush, and exit non-zero so the orchestrator restarts you.

Do not try to keep serving traffic after an uncaught exception. The official Node guidance is to treat it as a crash. Here is a complete, runnable illustration of the listener firing exactly once before exit.

let crashing = false;

process.on('uncaughtException', (err) => {
  if (crashing) return;
  crashing = true;
  console.error('uncaught:', err.message);
  // best-effort: flush logs/metrics here, then exit non-zero
  console.log('exiting with code 1');
  process.exit(1);
});

// simulate a fatal bug somewhere deep in the app
setImmediate(() => {
  throw new Error('boom: null pointer in handler');
});

console.log('server running, awaiting the crash');

Quick Check: The Drain Race

You deploy a Node service to Kubernetes with terminationGracePeriodSeconds: 30. On SIGTERM you immediately call server.close(() => process.exit(0)) and nothing else. In production you still observe occasional 502 errors for clients during rollouts. What is the most likely cause and the correct fix?

Recap: The Graceful Shutdown Checklist

You now have a complete, production-ready shutdown path. The essentials:

  • Handle SIGTERM and SIGINT, and make the handler idempotent with a guard.
  • Fail the readiness probe first so the load balancer stops sending new traffic before you drain.
  • server.close() stops new connections and drains in-flight requests; tame keep-alive by sending Connection: close and destroying idle sockets.
  • Always set a hard timeout shorter than the orchestrator's grace period, with unref(), exiting non-zero if draining hangs.
  • Close resources in order: HTTP server, then consumers/broker, then the DB pool, then flush telemetry.
  • Treat uncaughtException/unhandledRejection as crashes: best-effort cleanup, then exit non-zero for a restart.

Get these right and zero-downtime deploys stop dropping user requests.

Frequently asked questions

Is the “Graceful Shutdown and In-Flight Request Draining” lesson free?

Yes — the full text of “Graceful Shutdown and In-Flight Request Draining” is free to read here on the web, and the Node.js Backend Development Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Node.js Backend Development Bootcamp course, upgrade to CoddyKit PRO.

What will I learn in “Graceful Shutdown and In-Flight Request Draining”?

Handle SIGTERM to drain connections, finish work, and close resources cleanly during deploys. You practise Node.js Backend Development Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Node.js Backend Development Bootcamp?

No prior experience is required. Node.js Backend Development Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Graceful Shutdown and In-Flight Request Draining” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Node.js Backend Development Bootcamp lesson?

Yes. Every Node.js Backend Development Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Timeouts, Retries, and Exponential Backoff with Jitter
  2. The Circuit Breaker Pattern and Bulkhead Isolation
  3. Graceful Shutdown and In-Flight Request Draining
  4. Health Checks, Readiness Probes, and Load Shedding
← Back to Node.js Backend Development Bootcamp