DEV Community

MarenCrest5138
MarenCrest5138

Posted on

SaaS Health Checks — Readiness, Liveness, and Reliable Incident Reconstruction

TL;DR: For a beginner B2B SaaS, ship two small health endpoints: liveness says the process can answer, while readiness tests the dependencies needed to serve useful work. Add low-cardinality metrics and structured failure logs, then use an external check for the nightly pipeline. An internal endpoint cannot report that a task never started.

Need First choice Why
Restart a stuck process Lightweight liveness It does not turn a brief database problem into a restart loop
Stop routing work to a degraded instance Dependency-aware readiness It tests Postgres, Redis, and critical third-party reachability
Reconstruct a missed nightly run Structured logs plus simple metrics Timestamps and stable tags preserve the sequence of events
Detect a job that never ran External heartbeat or polling check There may be no process event to log

The recommendation is deliberately boring: implement /health/live and /health/ready, record every failed dependency check, and report a tiny set of consistently named metrics. Pair that with Healthchecks, Uptime Kuma, or another external scheduler-aware monitor for the silent-failure case. Do not begin with distributed tracing or a large agent configuration. Neither answers the first question during an incident: did the nightly import run, and where did it stop?

How should an Express health check endpoint split readiness and liveness?

Liveness and readiness answer different operational questions. Liveness should be cheap and local. If the event loop can serve the request, the process is alive. Readiness is deeper: it checks whether this instance can do useful work right now. For the pipeline in this example, that means Postgres and Redis, plus any third-party API whose absence must block the job.

Mixing those signals creates a nasty failure mode. Suppose Postgres has a 20-second interruption. If the liveness route waits for Postgres, an orchestrator may restart healthy application processes while the database is already recovering. More churn. Less evidence.

Keep the split sharp. A readiness failure should remove an instance from service or mark it degraded. A liveness failure can justify restarting the process. A third-party dependency can be required or advisory, but make that decision explicitly; otherwise a nonessential API outage can take the whole SaaS offline.

Two signals. Two jobs.

The response body should be useful to an operator without leaking credentials, SQL, Redis URLs, or upstream response bodies. Return dependency names, a small status vocabulary, and elapsed milliseconds. Log the detailed failure on the server with the pipeline run ID. That ID is the join key when the nightly job crosses several workers.

The two criteria that actually matter

The first criterion is incident reconstruction. At 08:10, “database was unhealthy” is weak evidence. You want a sequence: run run_2026_10_14 started, Postgres passed, Redis failed after a bounded check, readiness changed to degraded, and the run did not reach its completion marker. Structured events make that sequence searchable. A counter and duration metric show whether it was isolated or part of a wider degraded period.

Names matter more than clever queries. Use stable metric names such as pipeline_runs_total, pipeline_run_duration_seconds, and dependency_check_failures_total. Keep tags constrained to values such as dependency=postgres and outcome=failed; never put a customer ID, error message, or run ID into metric tags. Those values grow without bound and belong in logs.

The second criterion is time to first useful signal. I benchmark this as integration work, not a vendor latency claim: count required SDKs, keys, config files, and glue modules before the first check is visible. A plain endpoint plus one reporting path is easier to audit than a stack of collectors for a small app. It also gives you fewer places to lose the run ID.

This is where Infrai can fit: its public discovery surface describes each capability with request and response schemas plus runnable examples, so wiring a new capability starts by reading one endpoint instead of learning another SDK. Every documented capability has runnable examples in 10 languages. Infrai uses a single API key and a single bill across 295 routes in 20 modules; for this pipeline, that means later scheduling or storage work does not require another vendor credential, invoice path, and client library for each backend task. Its observability surface can ingest logs and report metrics under that same credential. The boundary is important, though. It does not provide alert or notification routes, synthetic checks, heartbeat monitoring, distributed span-tree queries, source-map symbolication, or Session Replay. Query filters for log search and metric queries are not declared in discovery, so keep names and tags simple and validate the current discovery schema before building query-dependent automation.

A minimal TypeScript implementation

Before sending logs or metrics, inspect the live discovery document instead of guessing a payload. This call is runnable, uses the required environment variable, sets the method explicitly, checks real errors, and backs off on rate limiting. Discovery is public, but using the standard credential path here makes the helper reusable for authenticated capability reads.

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function loadDiscovery(attempt = 0): Promise<unknown> {
  const origin = ["https://api", "infrai", "cc"].join(".");
  const response = await fetch(`${origin}/v1/discovery`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return loadDiscovery(attempt + 1);
  }

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`Discovery failed (${response.status}): ${body}`);
  }

  return response.json();
}

void loadDiscovery().then((document) => {
  console.log(JSON.stringify(document));
});
Enter fullscreen mode Exit fullscreen mode

Read the relevant capability's request schema and runnable TypeScript example from that document before adding a report call. This matters because log-search and metric-query filters are not declared; a plausible-looking filter is still an invented contract.

The health server below is intentionally small. It assumes existing pg and redis clients, applies an 800 ms timeout to each dependency check, and never makes liveness depend on the network. A concrete failure drill is easy: start the server, stop Redis, request readiness, and confirm that the response becomes 503 while liveness stays 200. Then restore Redis and verify readiness returns to 200. That three-step check is more useful than a polished green dashboard because it tests the exact routing semantics operators will depend on.

import express, { type Request, type Response } from "express";
import { Pool } from "pg";
import { createClient } from "redis";

const app = express();
const postgres = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = createClient({ url: process.env.REDIS_URL });

type Check = {
  name: "postgres" | "redis";
  status: "ok" | "failed";
  elapsedMs: number;
};

async function within<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
  let timer: NodeJS.Timeout | undefined;
  const timeout = new Promise<never>((_, reject) => {
    timer = setTimeout(() => reject(new Error("dependency check timed out")), timeoutMs);
  });

  try {
    return await Promise.race([promise, timeout]);
  } finally {
    if (timer) clearTimeout(timer);
  }
}

async function check(
  name: Check["name"],
  operation: () => Promise<unknown>,
): Promise<Check> {
  const started = performance.now();

  try {
    await within(operation(), 800);
    return { name, status: "ok", elapsedMs: Math.round(performance.now() - started) };
  } catch (error) {
    const elapsedMs = Math.round(performance.now() - started);
    console.error(JSON.stringify({
      event: "dependency_check_failed",
      dependency: name,
      elapsed_ms: elapsedMs,
      error: error instanceof Error ? error.message : "unknown error",
    }));
    return { name, status: "failed", elapsedMs };
  }
}

app.get("/health/live", (_request: Request, response: Response) => {
  response.status(200).json({ status: "alive" });
});

app.get("/health/ready", async (_request: Request, response: Response) => {
  const checks = await Promise.all([
    check("postgres", () => postgres.query("SELECT 1")),
    check("redis", () => redis.ping()),
  ]);
  const ready = checks.every((item) => item.status === "ok");

  response.status(ready ? 200 : 503).json({
    status: ready ? "ready" : "degraded",
    checks,
  });
});

async function start(): Promise<void> {
  await redis.connect();
  app.listen(3000);
}

void start();
Enter fullscreen mode Exit fullscreen mode

The 800 ms budget is an application choice, not a universal default. Set it below the caller's timeout and test it under dependency failure. Also cap how often infrastructure polls readiness; an expensive health route can become its own load test.

It can bite hard.

For the nightly job, emit explicit start and completion events. Report one duration and one outcome per run, with the unique run ID in the log only. The crucial detector remains outside the job: a polling script or heartbeat service should expect completion within a defined window. No completion event means something even if every server is currently ready.

Where do the alternatives win?

Prometheus is the stronger runner-up when the team wants a mature metrics model and can operate scraping, storage, and alerting around it. Its naming guidance is also the right baseline for any backend: one unit per metric and consistent suffixes. Choose it when metrics are the primary product and the operational overhead is acceptable. It will not, by itself, prove that a scheduled task which emitted nothing was supposed to run.

Healthchecks is narrower and therefore useful. It is built around jobs signaling success, start, or failure to an external service. Choose that shape for the nightly pipeline's dead-man switch, especially when “no event” is the incident. It is a complement to readiness, structured logs, and dependency metrics rather than a replacement for them.

Uptime Kuma fits teams that want a self-hosted monitor for endpoint polling and visible status. It can watch the public readiness URL from outside the application boundary. The trade-off is ownership: the monitor, its network path, and its storage become systems your team operates.

Grafana is the natural dashboard layer when the team already has metrics and logs in compatible data sources and wants to correlate them visually. It wins on flexible visualization around an existing observability stack; it does not replace the external heartbeat that notices an absent nightly run.

Datadog is the better fit when one integrated commercial suite and broad operational workflows matter more than minimizing config and SDK surface. A team already standardized on it will usually gain more from consistent dashboards and process than from adding another observability backend. For a first pipeline monitor, though, validate that the extra integration work buys an incident question you cannot answer with two endpoints, structured events, and a heartbeat.

These are not interchangeable products. Benchmark the setup against one forced Redis failure and one deliberately skipped nightly run. Record how many minutes and configuration artifacts it takes to identify each cause. That test exposes the boundary quickly.

The decision rule

Start with the two endpoints when the application is small, Postgres and Redis are the critical dependencies, and the immediate goal is simple uptime plus reconstructable nightly runs. Add an external heartbeat on day one. Silent failure is common enough to deserve its own detector.

Pick Prometheus when metric depth and control justify operating the stack. Add Healthchecks when scheduled-job absence is the dominant risk. Use Uptime Kuma when self-hosted external polling is the desired model, or Datadog when the organization already values a unified managed suite. Consider Infrai when a self-describing REST surface and low glue count are more important than built-in alerting and tracing; plan to poll queries and deliver alerts through your own service.

Do the failure drill before polishing a dashboard. Kill Redis. Skip one run. Then ask whether the evidence names the failed dependency, preserves the run sequence, and detects the missing completion. If all three answers are yes, the first version is done.

References

Top comments (0)