export type CheckResult = { name: string; status: 'up' | 'down'; durationMs: number; error?: string };
export type Check = () => Promise<void>;
function withTimeout(fn: Check, ms: number): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms);
fn().then(() => { clearTimeout(timer); resolve(); }, (e) => { clearTimeout(timer); reject(e); });
});
}
export class HealthRegistry {
private checks = new Map<string, { fn: Check; timeoutMs: number }>();
public shuttingDown = false;
register(name: string, fn: Check, timeoutMs = 2000): void {
this.checks.set(name, { fn, timeoutMs });
}
checkLiveness(): { status: 'up' | 'down'; uptimeSec: number } {
return { status: 'up', uptimeSec: Math.round(process.uptime()) };
}
async checkReadiness(): Promise<{ status: 'up' | 'down'; checks: CheckResult[] }> {
if (this.shuttingDown) {
return { status: 'down', checks: [{ name: 'process', status: 'down', durationMs: 0, error: 'shutting down' }] };
}
const entries = [...this.checks.entries()];
const settled = await Promise.allSettled(
entries.map(async ([name, { fn, timeoutMs }]) => {
const start = Date.now();
await withTimeout(fn, timeoutMs);
return { name, status: 'up' as const, durationMs: Date.now() - start };
})
);
const results: CheckResult[] = settled.map((s, i) => {
if (s.status === 'fulfilled') return s.value;
return { name: entries[i][0], status: 'down', durationMs: 0, error: String(s.reason?.message ?? s.reason) };
});
const status = results.every((r) => r.status === 'up') ? 'up' : 'down';
return { status, checks: results };
}
}
import type { FastifyInstance } from 'fastify';
import type { HealthRegistry } from './HealthRegistry';
export async function healthRoutes(app: FastifyInstance, registry: HealthRegistry): Promise<void> {
app.get('/healthz', async (_req, reply) => {
const live = registry.checkLiveness();
return reply.code(live.status === 'up' ? 200 : 503).send(live);
});
app.get('/readyz', async (_req, reply) => {
const ready = await registry.checkReadiness();
const code = ready.status === 'up' ? 200 : 503;
return reply.code(code).send({
status: ready.status,
checks: ready.checks,
checkedAt: new Date().toISOString(),
});
});
}
import Fastify from 'fastify';
import { Pool } from 'pg';
import { createClient } from 'redis';
import { HealthRegistry } from './HealthRegistry';
import { healthRoutes } from './health-routes';
async function main(): Promise<void> {
const app = Fastify({ logger: true });
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const registry = new HealthRegistry();
registry.register('postgres', async () => { await pool.query('SELECT 1'); }, 1500);
registry.register('redis', async () => { await redis.ping(); }, 1000);
await app.register(healthRoutes as any, registry);
await app.listen({ port: Number(process.env.PORT ?? 3000), host: '0.0.0.0' });
const drain = async (signal: string): Promise<void> => {
app.log.info({ signal }, 'received shutdown signal, failing readiness');
registry.shuttingDown = true;
// give load balancers time to observe /readyz returning 503
await new Promise((r) => setTimeout(r, 5000));
await app.close();
await pool.end();
await redis.quit();
process.exit(0);
};
process.on('SIGTERM', () => { void drain('SIGTERM'); });
process.on('SIGINT', () => { void drain('SIGINT'); });
}
main().catch((err) => { console.error(err); process.exit(1); });
This snippet shows how to distinguish liveness from readiness in a Node service and expose both to Kubernetes correctly. The distinction matters: a liveness probe answers "is the process wedged and does it need a restart?", while a readiness probe answers "can this pod safely receive traffic right now?". Conflating them is a common outage cause — a slow database check wired into liveness will cause Kubernetes to kill and restart otherwise-healthy pods during a dependency blip, amplifying the incident instead of shedding load.
In HealthRegistry, dependency checks are registered by name and executed in parallel with Promise.allSettled, each wrapped in a timeout via withTimeout so one hung dependency cannot stall the whole report. The registry separates checkLiveness, which only reports whether the event loop and process are alive, from checkReadiness, which aggregates all registered dependency checks into an overall status of up or down. The shuttingDown flag lets the process fail readiness deliberately during shutdown while still reporting liveness as healthy — this is the key trick for draining traffic gracefully.
The health routes tab maps these to two endpoints. /healthz (liveness) returns 200 unless the process is truly broken, so restarts stay rare. /readyz (readiness) returns 503 when any dependency is down or the pod is shutting down, which tells the service mesh to stop routing to it. Each check contributes structured timing data so the response doubles as a lightweight diagnostic. Returning 503 rather than throwing keeps the endpoint cheap and avoids polluting error logs during expected transient failures.
The server bootstrap tab wires real checks — a pg ping and a Redis PING — and installs a SIGTERM handler that flips shuttingDown before closing the server. This ordering is deliberate: Kubernetes sends SIGTERM, the pod begins failing /readyz, endpoints drain over the next few probe intervals, and only then does app.close() run, avoiding dropped in-flight requests. The trade-off is a short delay on shutdown, tuned to match the probe periodSeconds. Reach for this pattern any time a service has downstream dependencies and runs behind an orchestrator that expects honest health signals.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
use tracing::{info, instrument};
#[instrument]
fn process_request(user_id: u64) {
info!(user_id, "Processing request");
// Work happens here
tracing for structured logging and distributed tracing
class AddSettingsToAccounts < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :accounts, :settings, :jsonb, null: false, default: {}
Postgres JSONB Partial Index for Feature Flags
Share this code
Here's the card — post it anywhere.