
Table of Contents
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.
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.
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.
- Create a counter variable scoped to your server module.
- Add middleware at the very top of your stack that increments on request start and decrements on response finish.
- Use the
response.on('finish')event rather than middleware next() to ensure accurate counting even for aborted requests. - 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.
| Parameter | Liveness Recommendation | Readiness Recommendation | Rationale |
|---|---|---|---|
| initialDelaySeconds | 0–5 | 5–15 | Liveness starts immediately; readiness waits for boot |
| periodSeconds | 10–20 | 5–10 | Readiness needs faster detection of state changes |
| timeoutSeconds | 1–3 | 2–5 | Allow dependency checks time without hanging |
| failureThreshold | 3–5 | 2–3 | Liveness tolerates transient blips; readiness reacts faster |
| successThreshold | 1 | 1–2 | Require consecutive successes before re-admitting traffic |
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.