Health Check Endpoints
Health Check Endpoints
Health check endpoints are HTTP routes that expose the current state of your application to external systems like load balancers, orchestrators (Kubernetes, ECS), and monitoring tools. A well-designed health endpoint returns structured JSON indicating whether the service is alive, ready to serve traffic, and the status of its dependencies.
Anatomy of a Health Endpoint
A production health endpoint should check multiple dimensions:
- Liveness: Is the process running and not deadlocked?
- Readiness: Can the service accept new requests right now?
- Dependencies: Are databases, caches, and external APIs reachable?
Implementation Example
import express from 'express';
import { Pool } from 'pg';
import Redis from 'ioredis';
const app = express();
const dbPool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL);
interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
uptime: number;
timestamp: string;
checks: {
database: CheckResult;
redis: CheckResult;
memory: CheckResult;
};
}
interface CheckResult {
status: 'pass' | 'fail';
latencyMs: number;
error?: string;
}
async function checkDatabase(): Promise<CheckResult> {
const start = Date.now();
try {
await dbPool.query('SELECT 1');
return { status: 'pass', latencyMs: Date.now() - start };
} catch (err) {
return {
status: 'fail',
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : 'Unknown error',
};
}
}
async function checkRedis(): Promise<CheckResult> {
const start = Date.now();
try {
await redis.ping();
return { status: 'pass', latencyMs: Date.now() - start };
} catch (err) {
return {
status: 'fail',
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : 'Unknown error',
};
}
}
function checkMemory(): CheckResult {
const start = Date.now();
const mem = process.memoryUsage();
const heapUsedMB = mem.heapUsed / 1024 / 1024;
const heapTotalMB = mem.heapTotal / 1024 / 1024;
const usagePercent = (heapUsedMB / heapTotalMB) * 100;
return {
status: usagePercent > 90 ? 'fail' : 'pass',
latencyMs: Date.now() - start,
};
}
app.get('/health', async (_req, res) => {
const [database, redis, memory] = await Promise.all([
checkDatabase(),
checkRedis(),
checkMemory(),
]);
const checks = { database, redis, memory };
const allPass = Object.values(checks).every(c => c.status === 'pass');
const anyFail = Object.values(checks).some(c => c.status === 'fail');
const status: HealthStatus['status'] = allPass ? 'healthy' : anyFail ? 'unhealthy' : 'degraded';
const statusCode = status === 'healthy' ? 200 : 503;
res.status(statusCode).json({
status,
uptime: process.uptime(),
timestamp: new Date().toISOString(),
checks,
});
});
Best Practices
- Always return a non-200 status code when the service is unhealthy so load balancers stop routing traffic
- Keep health checks lightweight—avoid expensive queries or external API calls
- Use timeouts on dependency checks to prevent the health endpoint itself from hanging
- Separate
/health/live(liveness) from/health/ready(readiness) for Kubernetes environments
Readiness Probes
Readiness Probes
Readiness probes determine whether a service is prepared to handle incoming requests. During startup, a service may need to establish database connections, warm caches, load configuration, or complete migrations. The readiness probe should return a non-ready status until all startup tasks are complete, preventing traffic from being routed prematurely.
Kubernetes Integration
Kubernetes uses readiness probes to decide whether to include a Pod in Service endpoints. If the readiness probe fails, the Pod is removed from the load balancer pool but the container continues running.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
spec:
replicas: 3
selector:
matchLabels:
app: api-server
template:
metadata:
labels:
app: api-server
spec:
containers:
- name: api
image: myapp:latest
ports:
- containerPort: 3000
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
successThreshold: 1
livenessProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 15
periodSeconds: 20
failureThreshold: 3
Implementation with Startup State Tracking
enum ServiceState {
Starting = 'starting',
Ready = 'ready',
Draining = 'draining',
Stopped = 'stopped',
}
class Application {
private state: ServiceState = ServiceState.Starting;
private dependenciesReady = false;
async initialize(): Promise<void> {
try {
await this.connectDatabase();
await this.warmCache();
await this.loadConfiguration();
this.dependenciesReady = true;
this.state = ServiceState.Ready;
console.log('Application is ready to serve traffic');
} catch (err) {
console.error('Failed to initialize:', err);
this.state = ServiceState.Stopped;
process.exit(1);
}
}
async checkReadiness(): Promise<{ ready: boolean; reason?: string }> {
if (this.state === ServiceState.Starting) {
return { ready: false, reason: 'Application is still starting up' };
}
if (this.state === ServiceState.Draining) {
return { ready: false, reason: 'Application is draining connections' };
}
if (this.state === ServiceState.Stopped) {
return { ready: false, reason: 'Application has stopped' };
}
if (!this.dependenciesReady) {
return { ready: false, reason: 'Dependencies are not ready' };
}
return { ready: true };
}
}
const app = new Application();
app.get('/health/ready', async (_req, res) => {
const { ready, reason } = await app.checkReadiness();
res.status(ready ? 200 : 503).json({ ready, reason });
});
Key Considerations
- Set
initialDelaySecondslong enough for your application to complete startup initialization - Tune
periodSecondsbased on how quickly your service can recover from a temporary dependency failure - Use
successThreshold: 1so the Pod is re-added to the pool immediately when it becomes healthy again - Readiness probes should not check external dependencies aggressively—they only need to confirm internal state
Graceful Shutdown
Graceful Shutdown
Graceful shutdown ensures that when a process receives a termination signal, it stops accepting new work, finishes processing in-flight requests, cleans up resources, and then exits cleanly. Without graceful shutdown, active connections are dropped, database transactions are left in inconsistent states, and users experience errors during deployments.
Signal Handling
Node.js receives SIGTERM when a container is being stopped. You must listen for this signal and coordinate shutdown:
import express from 'express';
import http from 'http';
import { Pool } from 'pg';
const app = express();
const dbPool = new Pool({ connectionString: process.env.DATABASE_URL });
const server = http.createServer(app);
let isShuttingDown = false;
const DRAIN_TIMEOUT_MS = 30_000;
app.get('/api/orders', async (_req, res) => {
const result = await dbPool.query('SELECT * FROM orders WHERE status = $1', ['pending']);
res.json(result.rows);
});
app.get('/health/live', (_req, res) => {
if (isShuttingDown) {
return res.status(503).json({ alive: false });
}
res.json({ alive: true });
});
app.get('/health/ready', (_req, res) => {
if (isShuttingDown) {
return res.status(503).json({ ready: false });
}
res.json({ ready: true });
});
function gracefulShutdown(signal: string) {
console.log(`Received ${signal}. Starting graceful shutdown...`);
isShuttingDown = true;
const shutdownTimer = setTimeout(() => {
console.error('Drain timeout exceeded. Forcing shutdown.');
process.exit(1);
}, DRAIN_TIMEOUT_MS);
server.close(async () => {
console.log('HTTP server closed. No new connections accepted.');
try {
await dbPool.end();
console.log('Database pool closed.');
} catch (err) {
console.error('Error closing database pool:', err);
}
clearTimeout(shutdownTimer);
console.log('Graceful shutdown complete.');
process.exit(0);
});
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
server.listen(3000, () => {
console.log('Server listening on port 3000');
});
How It Works Step by Step
- SIGTERM received — Set
isShuttingDown = trueso health probes return 503 - Load balancer stops sending traffic — Kubernetes removes the Pod from endpoints because readiness probe fails
- server.close() — Stop accepting new HTTP connections while existing ones finish
- Drain in-flight requests — Let the event loop process remaining work
- Clean up resources — Close database pools, Redis connections, message queue consumers
- Exit — Call
process.exit(0)to signal clean termination
Common Pitfalls
- Forgetting to stop the health probe — If the health endpoint still returns 200 after SIGTERM, the load balancer keeps sending traffic
- No timeout — If cleanup hangs forever, the orchestrator will force-kill the process (SIGKILL), defeating the purpose
- Long-lived connections — WebSocket or SSE connections need explicit close logic with a timeout
- Background tasks — Jobs in queues should acknowledge completion or requeue before shutdown
Liveness Probes
Liveness Probes
Liveness probes detect whether an application is alive and functioning correctly. Unlike readiness probes which check if the service can handle new traffic, liveness probes check if the process itself is healthy. A failing liveness probe tells the orchestrator to restart the container because the application is in an unrecoverable state—such as a deadlock, infinite loop, or memory leak that prevents it from serving requests.
When to Use Liveness vs Readiness
| Probe | Purpose | Failure Action |
|---|---|---|
| Liveness | Is the process alive and not deadlocked? | Container restart |
| Readiness | Can the service accept new traffic? | Remove from load balancer |
Implementation Example
import express from 'express';
const app = express();
let lastHeartbeat = Date.now();
let requestCount = 0;
function recordActivity(): void {
lastHeartbeat = Date.now();
requestCount++;
}
app.use((_req, _res, next) => {
recordActivity();
next();
});
app.get('/health/live', (_req, res) => {
const now = Date.now();
const secondsSinceActivity = (now - lastHeartbeat) / 1000;
const HEARTBEAT_THRESHOLD = 60;
if (secondsSinceActivity > HEARTBEAT_THRESHOLD) {
return res.status(503).json({
alive: false,
reason: `No activity for ${secondsSinceActivity.toFixed(1)}s`,
});
}
res.json({
alive: true,
uptime: process.uptime(),
requestCount,
heapUsedMB: (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1),
});
});
setInterval(() => {
const mem = process.memoryUsage();
const heapUsedMB = mem.heapUsed / 1024 / 1024;
if (heapUsedMB > 500) {
console.warn(`High memory usage detected: ${heapUsedMB.toFixed(0)}MB`);
}
}, 30_000);
app.get('/api/work', async (_req, res) => {
const result = await performComputation();
res.json(result);
});
Kubernetes Liveness Probe Configuration
livenessProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
The initialDelaySeconds should be longer than your application's worst-case startup time to avoid premature restarts. The failureThreshold determines how many consecutive failures trigger a restart—setting it to 3 prevents restarts on transient issues.
Liveness Probe Anti-Patterns
- Checking external dependencies — If your database is temporarily down, a liveness probe will restart every Pod, causing a cascading failure. Use readiness probes for dependency checks instead
- Expensive computations — Liveness checks run frequently; keep them under 5ms
- Returning 200 when deadlocked — A deadlocked process may still respond to HTTP if the event loop thread is partially available. Use thread-level health checks or watchdog timers for more accurate detection
Quiz
1. What HTTP status code should a health endpoint return when the service is unhealthy?
2. During graceful shutdown, what should be the very first thing your application does after receiving SIGTERM?
3. Why should liveness probes avoid checking external dependencies like databases?
Flashcards
Question
What is the difference between a liveness probe and a readiness probe?
Click to reveal answer
Answer
A liveness probe checks whether the application process is alive and not deadlocked—failure triggers a container restart. A readiness probe checks whether the application is ready to accept new traffic—failure removes the Pod from the load balancer without restarting it. Liveness is for unrecoverable failures; readiness is for temporary unavailability.
Question
What is the correct order of steps during graceful shutdown?
Click to reveal answer
Answer
1) Receive SIGTERM signal. 2) Set shutdown flag so health probes return 503. 3) Call server.close() to stop accepting new connections. 4) Wait for in-flight requests to complete. 5) Close database pools and external connections. 6) Set a hard timeout to force exit if cleanup hangs. 7) Call process.exit(0).
Question
Why should health check endpoints be lightweight and fast?
Click to reveal answer
Answer
Health checks run on a short interval (typically every 5-10 seconds) by orchestrators and load balancers. If the health endpoint is slow or resource-intensive, it adds overhead to the application and can itself become a bottleneck. Slow health checks also delay detection of actual failures and can cause timeouts that trigger unnecessary restarts.
Revision Notes
Key Takeaways
- 1. Health endpoints should expose both liveness (is the process alive?) and readiness (can it accept traffic?) as separate routes
- 2. Always return non-200 status codes when unhealthy so load balancers stop routing traffic automatically
- 3. During graceful shutdown, set a flag to fail health probes first, then drain connections, then clean up resources with a hard timeout
- 4. Liveness probes must not check external dependencies—use readiness probes for dependency checks to prevent cascading restarts
- 5. Tune Kubernetes probe parameters (initialDelaySeconds, periodSeconds, failureThreshold) based on your application's startup time and recovery characteristics
Interview Tips
- • Explain the difference between liveness and readiness probes with concrete examples—interviewers want to see you understand the operational impact of each
- • Walk through graceful shutdown step by step, emphasizing why the order matters—starting with health probe failure before resource cleanup
- • Discuss what happens when you get the shutdown order wrong (dropped connections, data inconsistency, cascading failures)
- • Be ready to describe how you would monitor health check failures in production using tools like Prometheus, Datadog, or CloudWatch
- • Know the Kubernetes probe configuration options and their tradeoffs—setting initialDelaySeconds too low causes false restarts, too high delays failure detection
Cheat Sheet
Health checks: /health/live (liveness - process alive?), /health/ready (readiness - can serve traffic?). Return 503 when unhealthy. Graceful shutdown order: SIGTERM → set flag → fail health probes → server.close() → drain in-flight → close pools → timeout → exit. Liveness: container restart on failure. Readiness: remove from load balancer on failure. Never check external deps in liveness probes. Always set a hard shutdown timeout (30s) to prevent hanging. Kubernetes: initialDelaySeconds > startup time, failureThreshold >= 3 for transient resilience.