Graceful Shutdown and Health Checks in Node.js

Khimananda Oli 8 min read Programming and Languages
Graceful Shutdown and Health Checks in Node.js

By Khimananda Oli | Last reviewed: August 2026

Dropped requests during deployments remain one of the most common causes of user-facing errors in Node.js microservices, even in 2026. Properly implementing graceful shutdown and health checks in Node.js is not optional for production systems running on Kubernetes or behind load balancers; it is the difference between seamless updates and intermittent 502 Bad Gateway errors. This guide provides the exact signal handling patterns, HTTP probe endpoints, and configuration required to make your application truly cloud-native.

RunningAccepting TrafficSIGTERMDrainingStop New / Finish ActiveIdleCleanupClose DB / RedisExit 0Readiness = FAIL
Lifecycle of graceful shutdown and health checks in Node.js: SIGTERM triggers draining, readiness fails immediately, and cleanup precedes exit.

How do you handle SIGTERM for graceful shutdown in Node.js?

The default behavior of Node.js when receiving a SIGTERM signal (the standard termination signal sent by Kubernetes, Docker, and systemd) is to exit immediately. This abrupt exit severs active TCP connections, causing clients to receive connection reset errors or incomplete responses. To achieve true graceful shutdown and health checks in Node.js, you must intercept this signal and orchestrate a controlled wind-down.

A common mistake I see in code reviews is attempting to close the database before stopping the HTTP server. The correct order is critical: first, signal to the outside world that you are no longer available (by failing readiness probes), then stop accepting new connections, wait for in-flight requests to complete, and only then release external resources. If you close the database while requests are still processing, those requests will fail with database errors rather than completing successfully.

Implementing the signal handler

Your shutdown handler must be idempotent and respect a hard timeout. Orchestrators typically allow a grace period (default 30s in Kubernetes); if your process exceeds this, it receives SIGKILL. Always set an internal timeout slightly shorter than the infrastructure limit to allow your own cleanup logic to run.

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

let isShuttingDown = false;

async function gracefulShutdown(signal) {
  if (isShuttingDown) return;
  isShuttingDown = true;

  console.log(`${signal} received. Starting graceful shutdown...`);

  // 1. Stop accepting new connections immediately
  server.close((err) => {
    if (err) {
      console.error('Error closing server:', err);
      process.exit(1);
    }
    console.log('HTTP server closed. In-flight requests completing...');
  });

  // 2. Set hard timeout (slightly less than K8s terminationGracePeriodSeconds)
  const forceExitTimeout = setTimeout(() => {
    console.error('Forced exit after timeout. Some requests may have been dropped.');
    process.exit(1);
  }, 25000);

  // 3. Wait for active connections to drain (implement tracking middleware)
  await waitForActiveRequests();

  // 4. Close external connections
  await db.disconnect();
  await redis.quit();
  console.log('External connections closed.');

  clearTimeout(forceExitTimeout);
  process.exit(0);
}

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

This pattern ensures that blue-green and canary deploys on Kubernetes do not cause user-visible errors during pod rotation. The key insight is that server.close() stops accepting new connections but allows existing ones to finish naturally.

What is the difference between liveness and readiness probes in Node.js?

Many developers conflate liveness and readiness, exposing a single /health endpoint that checks database connectivity. This is an anti-pattern that causes cascading failures. Understanding the distinction is fundamental to reliable graceful shutdown and health checks in Node.js.

  • Liveness Probe: Answers "Is this process dead?" It should only check if the Node.js event loop is responsive. Never include database or cache checks here. If a liveness probe fails, Kubernetes kills and restarts the pod. A slow database query should never trigger a pod restart.
  • Readiness Probe: Answers "Can this pod serve traffic right now?" This checks dependencies (DB, Redis, message queues) and internal state (cache warmed, migrations complete). If readiness fails, the pod is removed from service endpoints but continues running.
  • Startup Probe: For applications with long initialization times (loading large ML models, warming caches). This disables liveness/readiness checks until startup completes, preventing premature kills during boot.
Liveness Probe"Is the process alive?"Event Loop Responsive?Memory Under Limit?Database Connected? (NO!)FAIL → Restart PodReadiness Probe"Can it serve traffic?"Event Loop Responsive?Database Connected?Cache Warmed / Migrations Done?FAIL → Remove from LB Only
Liveness probes should never check dependencies; readiness probes determine traffic eligibility for graceful shutdown and health checks in Node.js.

Production-ready probe implementation

Keep liveness extremely cheap. Readiness can perform actual checks but must have timeouts to avoid hanging the probe itself.

// Liveness: Always fast, no external deps
app.get('/health/live', (req, res) => {
  res.status(200).json({ status: 'ok' });
});

// Readiness: Check actual dependencies with timeouts
app.get('/health/ready', async (req, res) => {
  if (isShuttingDown) {
    return res.status(503).json({ status: 'shutting_down' });
  }

  try {
    const [dbOk, redisOk] = await Promise.all([
      checkWithTimeout(db.ping(), 2000),
      checkWithTimeout(redis.ping(), 2000)
    ]);

    if (dbOk && redisOk) {
      res.status(200).json({ status: 'ready' });
    } else {
      res.status(503).json({
        status: 'unavailable',
        details: { db: dbOk, redis: redisOk }
      });
    }
  } catch (err) {
    res.status(503).json({ status: 'error', message: err.message });
  }
});

function checkWithTimeout(promise, ms) {
  return Promise.race([
    promise.then(() => true),
    new Promise(resolve => setTimeout(() => resolve(false), ms))
  ]);
}

This separation prevents the "restart storm" problem where a temporarily overloaded database causes every pod to restart simultaneously, making the outage worse. For deeper observability integration, consider how these probes interact with your broader monitoring strategy as discussed in the four golden signals of monitoring.

How do you track active requests during Node.js shutdown?

Calling server.close() stops new connections but gives you no visibility into when existing requests complete. Without tracking, you either exit too early (dropping requests) or wait blindly (delaying deployments). You need middleware that maintains an accurate counter of in-flight requests.

  1. Create a counter variable scoped to your server module.
  2. Add middleware at the very top of your stack that increments on request start and decrements on response finish.
  3. Use the response.on('finish') event rather than middleware next() to ensure accurate counting even for aborted requests.
  4. Expose a promise-based waiter that resolves when the counter reaches zero.
let activeRequests = 0;
let resolveDrained = null;

app.use((req, res, next) => {
  activeRequests++;
  res.on('finish', () => {
    activeRequests--;
    if (activeRequests === 0 && resolveDrained) {
      resolveDrained();
    }
  });
  next();
});

function waitForActiveRequests(timeoutMs = 20000) {
  if (activeRequests === 0) return Promise.resolve();
  return new Promise((resolve) => {
    resolveDrained = resolve;
    setTimeout(resolve, timeoutMs);
  });
}

This pattern works correctly with streaming responses, WebSockets (if tracked separately), and aborted client connections. The timeout parameter acts as a safety valve; if a request hangs indefinitely during shutdown, you still exit cleanly rather than blocking forever.

How do you configure Kubernetes probes for Node.js applications?

Your application code is only half the equation. Misconfigured Kubernetes probe parameters will undermine even perfect Node.js implementations. These values must align with your application's actual startup time, latency percentiles, and shutdown duration.

ParameterLiveness RecommendationReadiness RecommendationRationale
initialDelaySeconds0–55–15Liveness starts immediately; readiness waits for boot
periodSeconds10–205–10Readiness needs faster detection of state changes
timeoutSeconds1–32–5Allow dependency checks time without hanging
failureThreshold3–52–3Liveness tolerates transient blips; readiness reacts faster
successThreshold11–2Require consecutive successes before re-admitting traffic
Pod StartPod TerminatedStartup ProbeReadiness = PASS (Traffic Added)Liveness Active (Continuous)SIGTERMReadiness = FAILDrain + Cleanup (25s max)
Kubernetes probe timeline aligned with graceful shutdown and health checks in Node.js: readiness fails immediately on SIGTERM while liveness continues.

Critical YAML configuration

Note the explicit path differentiation and the termination grace period buffer. Your terminationGracePeriodSeconds must exceed your internal shutdown timeout.

spec:
  terminationGracePeriodSeconds: 30
  containers:
  - name: api
    ports:
    - containerPort: 3000
    startupProbe:
      httpGet:
        path: /health/live
        port: 3000
      initialDelaySeconds: 0
      periodSeconds: 2
      failureThreshold: 30
    livenessProbe:
      httpGet:
        path: /health/live
        port: 3000
      periodSeconds: 15
      timeoutSeconds: 2
      failureThreshold: 3
    readinessProbe:
      httpGet:
        path: /health/ready
        port: 3000
      periodSeconds: 5
      timeoutSeconds: 3
      failureThreshold: 2
      successThreshold: 1

The startup probe here allows up to 60 seconds (30 × 2s) for initialization before liveness kicks in. This prevents the common issue where slow-starting Node.js apps get killed before they ever become ready. For teams managing complex deployments, understanding this interaction is essential alongside strategies covered in horizontal pod autoscaling in Kubernetes.

Reliable Deployments Start With Correct Shutdown Logic

Getting graceful shutdown and health checks in Node.js right eliminates an entire category of production incidents that masquerade as network issues or platform bugs. The implementation requires discipline: separate your probes, track active connections explicitly, respect signal ordering, and align your Kubernetes configuration with your application's actual behavior. Test your shutdown path as rigorously as you test your happy path—send SIGTERM during load tests and verify zero dropped requests. If your team needs help auditing your current Node.js deployment reliability or designing compliant infrastructure, reach out to discuss your architecture.

Frequently Asked Questions

It is the process of stopping new requests, finishing active ones, and closing resources before exiting.

They prevent load balancers from routing traffic to unresponsive instances during deployments or failures.

Listen for process.on SIGTERM, stop accepting connections, drain active requests, then call process.exit.

Liveness detects deadlocks requiring restarts while readiness confirms the app can accept traffic safely.

Return 503 Service Unavailable so orchestrators know to stop sending traffic immediately.

Set terminationGracePeriodSeconds slightly longer than your slowest expected request drain time in 2026.

Yes, create a dedicated route that checks database and cache connectivity before returning 200 OK.

The container runtime sends SIGKILL after the grace period, forcefully terminating active connections and corrupting state.

Readiness probes should check critical dependencies like databases but avoid flaky third-party APIs to prevent cascading failures.

Stop accepting new upgrades, send close frames to existing clients, and wait for acknowledgments before exiting.

Use Prometheus metrics with a custom counter tracking active requests and shutdown duration during deploys.

Yes, nodemon sends SIGUSR2 by default so configure it to forward SIGTERM for accurate local testing.

Send curl requests then run kill -SIGTERM pid and verify responses complete before the process exits.

Event loop blocking or garbage collection pauses often delay responses beyond the probe timeout threshold.

Terminus and http-terminator are widely used in 2026 for managing signals and health endpoints reliably.