Production-Ready Health Checks in Node.js: Liveness, Readiness, and Kubernetes Probes
A practical, code-heavy guide to implementing deep, non-cascading liveness, readiness, and startup health checks in Node.js for Kubernetes environments.
Production-Ready Health Checks in Node.js: Liveness, Readiness, and Kubernetes Probes
When deploying Node.js applications to containerized environments like Kubernetes, relying on a simple GET /health endpoint that returns 200 OK as long as the event loop is ticking is a recipe for silent outages. In modern microservice architectures, orchestrators need granular visibility into what state your application is in.
Is the server merely alive, or can it actually handle traffic? Has it finished heavy initialization tasks like running database migrations or loading caches? If a downstream dependency like PostgreSQL or Redis goes down, how do you prevent your health check from accidentally triggering a cascading restart loop across your entire cluster?
In this guide, we will design and implement a robust, production-grade health checking architecture in Node.js, covering Startup, Liveness, and Readiness probes, backed by safe dependency checking and memory threshold monitoring.
—.—-
The Three Pillars of Kubernetes Probes
Before writing code, it is critical to understand the separation of concerns among the three Kubernetes probe types:
- Startup Probe (
/health/startup): Tells Kubernetes whether the application has started. All other probes are disabled until this succeeds. Essential for legacy apps or Node.js services with heavy startup routines. - Liveness Probe (
/health/liveness): Tells Kubernetes whether the application is running healthily. If this fails, Kubernetes kills the container and restarts it. Should only check internal state (e.g., event loop lockup), not external dependencies. - Readiness Probe (
/health/readiness): Tells Kubernetes whether the application is ready to accept traffic. If this fails, the pod is removed from the service endpoints load balancer without being restarted.
The Golden Rule: Never check external dependencies (databases, caches) inside a Liveness probe. If your database drops, you do not want Kubernetes to restart all your backend pods simultaneously, which creates a thundering herd problem.
—.—-
Setting Up the Express Application Framework
Let’s build a modular health-check infrastructure using Express. We will structure our application to handle checks asynchronously with strict timeouts to prevent hanging health checks from freezing orchestrator workers.
First, install the necessary dependencies:
npm install express pg ioredis
Creating the Health Check Manager
We will create a dedicated service class that manages our checks, handles timeouts, and evaluates thresholds.
// healthService.ts
import { Pool } from 'pg';
import Redis from 'ioredis';
interface HealthCheckResult {
status: 'UP' | 'DOWN';
details?: Record<string, any>;
error?: string;
}
export class HealthService {
constructor(private dbPool: Pool, private redisClient: Redis) {}
// Check if event loop is blocked
public checkLiveness(): HealthCheckResult {
const memUsage = process.memoryUsage();
const maxHeapMB = 1024; // 1GB limit threshold
const currentHeapMB = memUsage.heapUsed / 1024 / 1024;
if (currentHeapMB > maxHeapMB) {
return {
status: 'DOWN',
error: `Memory threshold exceeded: ${currentHeapMB.toFixed(2)}MB / ${maxHeapMB}MB`,
};
}
return {
status: 'UP',
details: {
heapUsedMB: currentHeapMB.toFixed(2),
uptimeSeconds: process.uptime(),
},
};
}
// Check critical external dependencies with a strict timeout
public async checkReadiness(): Promise<HealthCheckResult> {
const timeoutMs = 3000;
const checks: Record<string, string> = {};
let isHealthy = true;
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Health check timed out')), timeoutMs)
);
try {
// Check PostgreSQL
await Promise.race([
this.dbPool.query('SELECT 1'),
timeout,
]);
checks.database = 'UP';
} catch (err: any) {
isHealthy = false;
checks.database = `DOWN: ${err.message}`;
}
try {
// Check Redis
await Promise.race([
this.redisClient.ping(),
timeout,
]);
checks.redis = 'UP';
} catch (err: any) {
isHealthy = false;
checks.redis = `DOWN: ${err.message}`;
}
return {
status: isHealthy ? 'UP' : 'DOWN',
details: checks,
};
}
// Check if initialization tasks are complete
public checkStartup(isInitialized: boolean): HealthCheckResult {
if (!isInitialized) {
return { status: 'DOWN', error: 'Application still initializing' };
}
return { status: 'UP' };
}
}
—.—-
Wiring Up the Endpoints
Now, let’s expose these checks via explicit Express routes. We will make sure to return appropriate HTTP status codes (200 OK for healthy, 503 Service Unavailable for unhealthy).
// server.ts
import express, { Request, Response } from 'express';
import { Pool } from 'pg';
import Redis from 'ioredis';
import { HealthService } from './healthService';
const app = express();
const dbPool = new Pool({ connectionString: process.env.DATABASE_URL });
const redisClient = new Redis(process.env.REDIS_URL);
const healthService = new HealthService(dbPool, redisClient);
let applicationInitialized = false;
// Startup Probe
app.get('/health/startup', (req: Request, res: Response) => {
const result = healthService.checkStartup(applicationInitialized);
const statusCode = result.status === 'UP' ? 200 : 503;
res.status(statusCode).json(result);
});
// Liveness Probe
app.get('/health/liveness', (req: Request, res: Response) => {
const result = healthService.checkLiveness();
const statusCode = result.status === 'UP' ? 200 : 503;
res.status(statusCode).json(result);
});
// Readiness Probe
app.get('/health/readiness', async (req: Request, res: Response) => {
const result = await healthService.checkReadiness();
const statusCode = result.status === 'UP' ? 200 : 503;
res.status(statusCode).json(result);
});
// Simulate application boot sequence
async function startServer() {
app.listen(3000, () => {
console.log('Server running on port 3000');
});
// Perform async setup tasks (migrations, cache priming, etc.)
try {
console.log('Running database migrations...');
await new Promise((resolve) => setTimeout(resolve, 5000)); // Mock delay
applicationInitialized = true;
console.log('Application initialization complete.');
} catch (err) {
console.error('Initialization failed:', err);
process.exit(1);
}
}
startServer();
—.—-
Configuring Kubernetes Manifests
Once your Node.js application exposes these clean endpoints, configuring them inside your Kubernetes Deployment YAML is straightforward. Pay close attention to parameters like initialDelaySeconds, periodSeconds, and failureThreshold.
apiVersion: apps/v1
kind: Deployment
metadata:
name: node-backend
spec:
replicas: 3
selector:
matchLabels:
app: node-backend
template:
metadata:
labels:
app: node-backend
spec:
containers:
- name: node-app
image: my-registry/node-backend:latest
ports:
- containerPort: 3000
resources:
limits:
memory: "1Gi"
cpu: "500m"
requests:
memory: "512Mi"
cpu: "250m"
startupProbe:
httpGet:
path: /health/startup
port: 3000
initialDelaySeconds: 2
periodSeconds: 5
failureThreshold: 12 # Gives app up to 60 seconds to finish startup
livenessProbe:
httpGet:
path: /health/liveness
port: 3000
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/readiness
port: 3000
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
—.—-
Best Practices and Pitfalls to Avoid
1. Protect Against Connection Pooling Exhaustion
If your readiness probe runs every 5 seconds across 20 pods, and each check opens a brand-new database connection, you will quickly exhaust your database’s connection pool. Always reuse existing connection pools (like pg.Pool or a singleton Redis client instance) for your health checks.
2. Implement Strict Timeouts
Never let a hanging socket freeze your health check endpoint. If your database hangs, a synchronous check will block the HTTP thread, resulting in a false positive timeout that causes Kubernetes to restart a perfectly healthy container. Always wrap external checks in Promise.race with a strict timeout (e.g., 2–3 seconds).
3. Handle Memory Leaks Gracefully via Liveness
Node.js has a fixed V8 heap limit (typically ~1.4GB on 64-bit systems by default if unconfigured). If your application suffers from a memory leak, V8 will throw an uncatchable FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed. Catching memory thresholds beforehand in your liveness probe allows Kubernetes to gracefully terminate and restart the pod before it crashes violently.
Conclusion
By splitting your health checks into Startup, Liveness, and Readiness probes, you transition from blind infrastructure management to precise, resilient orchestration. Your Node.js applications will handle rolling updates smoothly, isolate external database failures without triggering cascading restarts, and protect themselves against resource exhaustion.