DEV Community

ThatcherCole8235
ThatcherCole8235

Posted on

Choosing an Express Health Check Endpoint: When /ready and /live Diverge

The operational constraint decides the endpoint design: a restart signal and a traffic-routing signal must not share every dependency check. Short answer: use /live to answer whether the Node.js process can serve HTTP, /ready to decide whether this instance should receive traffic, and a small /health summary when an external uptime monitor needs one URL; explain the history with metrics and structured logs, not a giant probe response.

That split is the useful result. The evaluation constraint matters just as much: each negative response must map to one action that the deployment system or an operator can actually take. An all-in-one check is simpler on paper, but it cannot distinguish “restart this process” from “stop routing new work here.” It also encourages teams to stuff every database, queue, and downstream API into one request. Don't do that.

Keep each contract narrow.

How should a production Node.js Express health check combine metrics and logging?

Start with actions, not route names. Liveness answers whether the process is responsive enough to remain running. It should avoid network calls to dependencies: restarting every instance during a shared database interruption adds churn without repairing the database. Readiness answers a different question: can this particular instance perform the work required by its current role? It may include cheap, bounded checks for mandatory resources and local state such as startup or shutdown. /health can remain the deliberately limited surface consumed by a basic uptime monitor.

The boundary is intentionally asymmetric. A process may be live but not ready while it warms required resources or drains existing requests. An optional dependency can be unavailable while the core service stays ready, provided the application has an explicit degraded path. If no degraded path exists, that dependency isn't optional in operational terms.

A public probe should return very little. Status, at most. Dependency names, exception messages, environment values, build metadata, and credentials belong elsewhere because a probe is frequently unauthenticated. Detailed diagnosis can live in authenticated operational tooling, metrics, and logs. The probe itself is a decision interface — dull by design — rather than a miniature status dashboard.

The simple approach fails when it treats current state as history. A successful /ready response says nothing about how often readiness changed during the last deployment, and one slow /health request does not describe the request latency distribution. OpenTelemetry defines a metric as a measurement captured at runtime. That makes counters suitable for outcomes and histograms suitable for durations, while an event log keeps the context around a state transition.

Use stable metric attributes such as probe.name and probe.outcome. Never attach request IDs, user IDs, raw URLs, prompt text, or exception messages to a metric attribute: those values create an open-ended number of time series, which makes storage and queries harder to control. Logs can carry an existing correlation ID because they record individual events. RFC 5424 defines severity levels from Emergency through Debug; a team still has to map its application events to those levels consistently. A readiness transition is useful. A success log every few seconds is usually noise.

Signal Question it answers Useful fields Keep out
Probe response What should the caller do now? Minimal state Secrets and diagnostics
Metric Is behavior changing across requests? Outcome, count, duration, stable route Unbounded identifiers and raw content
Log What happened during this event? Severity, event name, version, correlation ID Credentials and routine probe spam

A focused TypeScript probe contract

This example keeps instrumentation behind a tiny interface so the HTTP contract does not depend on a particular telemetry destination. The required dependency check is injected, which also makes timeout, rejection, recovery, and shutdown states testable without changing route behavior.

import express, { NextFunction, Request, Response } from "express";

type ProbeName = "health" | "live" | "ready";
type ProbeOutcome = "ok" | "fail";

interface ProbeMetrics {
  count(name: ProbeName, outcome: ProbeOutcome): void;
  observeDuration(name: ProbeName, outcome: ProbeOutcome, milliseconds: number): void;
}

interface Logger {
  info(event: Record<string, unknown>): void;
  warn(event: Record<string, unknown>): void;
}

interface Dependencies {
  requiredReady(signal: AbortSignal): Promise<boolean>;
}

export function createApp(
  dependencies: Dependencies,
  metrics: ProbeMetrics,
  logger: Logger,
) {
  const app = express();
  const startedAt = Date.now();
  let acceptingTraffic = true;
  let previousReadiness: ProbeOutcome | undefined;

  function record(
    name: ProbeName,
    outcome: ProbeOutcome,
    started: number,
  ): void {
    metrics.count(name, outcome);
    metrics.observeDuration(name, outcome, performance.now() - started);
  }

  function measured(
    name: ProbeName,
    handler: (request: Request, response: Response) => void,
  ) {
    return (request: Request, response: Response, next: NextFunction): void => {
      const started = performance.now();
      response.once("finish", () => {
        const outcome: ProbeOutcome = response.statusCode < 400 ? "ok" : "fail";
        record(name, outcome, started);
      });

      try {
        handler(request, response);
      } catch (error) {
        next(error);
      }
    };
  }

  app.get("/live", measured("live", (_request, response) => {
    response.status(200).json({ status: "ok" });
  }));

  app.get("/health", measured("health", (_request, response) => {
    response.status(200).json({
      status: "ok",
      uptimeSeconds: Math.floor((Date.now() - startedAt) / 1_000),
    });
  }));

  app.get("/ready", async (_request, response, next) => {
    const started = performance.now();
    const signal = AbortSignal.timeout(750);

    try {
      const ready = acceptingTraffic && await dependencies.requiredReady(signal);
      const outcome: ProbeOutcome = ready ? "ok" : "fail";
      record("ready", outcome, started);

      if (outcome !== previousReadiness) {
        const event = { event: "readiness_changed", outcome };
        outcome === "ok" ? logger.info(event) : logger.warn(event);
        previousReadiness = outcome;
      }

      response
        .status(ready ? 200 : 503)
        .json({ status: ready ? "ready" : "not_ready" });
    } catch (error) {
      record("ready", "fail", started);
      next(error);
    }
  });

  process.on("SIGTERM", () => {
    acceptingTraffic = false;
  });

  return app;
}
Enter fullscreen mode Exit fullscreen mode

There are two details worth copying and several values that should not be copied blindly. First, instrumentation observes the response outcome instead of logging each successful request. Second, the shutdown handler makes the instance not ready before process exit, giving the router a chance to stop new work. The 750 millisecond deadline is only an example. I'm not sure one deadline can fit a local cache, a remote database, and an LLM request path; measured dependency latency and the platform's probe timeout should settle it.

The injected check must be cheap, read-only, and representative of required capacity. It should not insert a database row, consume a queue item, call an LLM, or execute a full synthetic transaction on every probe. Full-path synthetic checks can be valuable, but they have a different schedule, cost profile, and failure action. Mixing them into readiness makes routine orchestration traffic pay for deep diagnosis.

Error handling also deserves a deliberate decision. The example passes an unexpected exception to the application's normal Express error middleware, while a normal “not ready” result gets a compact 503 response. Those states shouldn't collapse into the same log event. One is an expected operational outcome; the other needs exception context in the protected log stream.

Test the actions, not just the JSON

A useful experiment compares the all-in-one baseline with the split contract under controlled state changes. Start the real server, exercise it through HTTP, and record status, response duration, readiness transitions, process restarts, application error rate, and application latency. Then vary one condition at a time: startup incomplete, mandatory dependency rejected, dependency response beyond the deadline, recovery, shutdown drain, and an optional feature unavailable. The expected behavior is specific: startup and draining can leave /live positive while /ready is negative; a mandatory dependency changes readiness; an optional feature does not evict the instance when a tested degraded path exists.

Now break it.

I would make the 503 case explicit in an integration test because a body-only assertion can miss the behavior a load balancer consumes. I would also send the test through the production-like proxy path. A localhost test cannot reveal a rewritten path or a shorter proxy timeout, and those are properties of the deployed system rather than the handler function.

One focused failure-injection run is more informative than repeatedly checking the happy path. Delay the injected readiness dependency beyond its deadline and verify that /ready finishes within the external probe budget, stays bounded under concurrent checks, and later recovers without a process restart. During the same run, /live should keep answering. Then examine the signals: the readiness outcome counter changes, the duration histogram exposes the slow check, and the transition log supplies event context without producing a line for every probe. This is an evaluation recipe, not a benchmark claim; the acceptable deadline and concurrency depend on the deployed service. Deployment tests should cover the mirror image too. On startup, don't accept user traffic until mandatory local initialization and required connections are ready. On termination, turn readiness negative, stop accepting new work, and allow in-flight work to finish within the hosting platform's termination window. Long-lived or streaming LLM responses make a copied grace period particularly risky, so measure their actual duration distribution before setting that window. Cost belongs in this experiment even without a vendor price table: count metric series, log events, retained bytes, and synthetic dependency calls. High-cardinality attributes can multiply series quietly; per-probe success logs create volume without much diagnostic value; deep probes can add paid downstream requests. A ship-first implementation starts with the signals tied to a decision, verifies that they answer incident questions, and adds dimensions only when a real query needs them.

Should every uptime monitor use three endpoints?

No. The three-route design is not suitable when the hosting environment has only one monitor and cannot distinguish restart from traffic removal. In that setup, stick with one minimal /health contract and put dependency diagnosis in telemetry or an authenticated operator view. A small service with no external dependency may also gain nothing from a separate readiness route.

There is another catch: readiness can protect traffic routing, but it cannot prove the whole user journey works. A shallow check may stay green while an authorization rule, prompt template, or business transaction is wrong. Scheduled synthetic tests and application-level service indicators cover that gap, at a lower frequency and with controlled test data. Conversely, a full transaction on every readiness request is expensive and can amplify an incident. Neither extreme is a production best practice by itself.

Before copying the design, measure the platform's probe frequency and timeout, startup duration, shutdown drain time, required dependency latency, readiness transition rate, restart count, application error rate, and tail latency. Check the label cardinality and daily log volume too. Those numbers reveal whether the probes detect a useful state, create extra load, or trigger the wrong recovery action.

The final rule is plain: a negative /live, /ready, or /health result needs one clear owner and one clear response. If operators cannot say what action follows, the endpoint is reporting trivia. Ship the smallest contract that controls the deployment correctly, then let metrics show patterns and logs explain transitions.

References

Top comments (0)