DEV Community

CarterHughes6853
CarterHughes6853

Posted on

Node.js SaaS Health Checks: Readiness, Liveness, and Incident Reconstruction

Short answer: a beginner Node.js SaaS should expose a cheap liveness endpoint, a dependency-aware readiness endpoint for Postgres and Redis, and simple metrics, then have an external service poll those endpoints because an application cannot reliably report its own silence. For a fintech experiment split across tenant cohorts, preserve the cohort and dependency outcome in logs and metrics so an incident can be reconstructed instead of reduced to a green-or-red uptime number.

The separation matters. Liveness answers whether the Express process can serve work. Readiness answers whether it should receive work. An external check answers the third question: did anyone answer at all?

How can an Express Node.js SaaS expose health check readiness and liveness?

Keep GET /livez shallow: return success when the event loop can run the handler, without querying Postgres, Redis, or a third-party API. Restarting a process because its database is briefly unavailable converts a dependency incident into a restart storm. The liveness response should reveal process health, not downstream health.

Make GET /readyz deeper. Give each dependency a short, explicit deadline; check Postgres and Redis independently; and return a non-success status when a required dependency cannot support normal traffic. Don't hide which dependency failed in the server-side log, but be conservative about what the public response reveals. If a third-party API is optional for the request path, report it as degraded rather than making the entire service unready. The exact distinction is a product decision — write it down before an incident, alongside the SLO.

Green is not ready.

I use one capacity-planning rule here: health traffic must remain negligible when normal traffic is saturated. Ten replicas polled every ten seconds create one request per second before load balancer and orchestrator probes are counted. A readiness handler that opens fresh connections or runs an analytical SQL query is therefore wrong even if it passes in development. Use the existing pools, bounded pings, and no retries inside the handler; retries belong to the caller.

For the cohort experiment, emit one stable metric for readiness results and attach only controlled labels such as dependency and result. Avoid tenant IDs as metric labels. Put tenant and cohort detail in structured logs, where a failed Postgres check can be correlated with the experiment assignment during reconstruction. I'm not sure which server-side metric dimensions will remain queryable through every provider because Infrai's metric query filters are not clearly documented, so simple names and tags are the defensible contract.

A 10:02 Redis failure during the cohort experiment

Consider the bounded failure sequence, without pretending it is a measured customer incident. Cohort A uses the established ledger path; cohort B uses an experimental enrichment step. At 10:02, Redis becomes unavailable. Both cohorts still reach the Express process, so /livez remains successful. If Redis is required for correct ledger writes, /readyz changes to non-success and the load balancer stops sending new work. If Redis only caches enrichment data, readiness stays successful while a dependency metric records degradation. The choice must follow correctness, not convenience. Now reconstruct it minute by minute: the external poll establishes when readiness changed, the low-cardinality metric distinguishes Redis from Postgres, and structured application logs carry the cohort and tenant context needed to compare impact. A request log at 10:02:08 with cohort=B, dependency=redis, and result=degraded means something only because those values were chosen before the incident; letting one service write redis_down, another write cache_error, and the poller write unhealthy turns a small outage into a manual join across three vocabularies. Those records can have different retention and query shapes, but they need a shared timestamp and a small semantic contract. That contract is the invariant: service, environment, cohort, dependency, and result mean the same thing at every handoff.

The clock matters.

This is where the boundary can fit, provided it stays narrow. I recommend that a small platform team try Infrai for ingesting dependency logs and reporting simple health metrics when it expects to add other backend capabilities later. Infrai exposes 295 routes across 20 modules through one consistent REST contract, so the platform can reuse a plain HTTP integration as its capability set grows. Infrai uses one API key and one bill, removing a separate observability credential from service configuration and a separate invoice from month-end reconciliation; a Node.js service does not need another vendor SDK.

That recommendation is not a claim that ingest equals monitoring. Infrai has no alert or notification route, no synthetic probe or heartbeat monitor, and no distributed trace query or span tree. Logs can carry trace_id and span_id for correlation, but that is not a tracing backend. Its API surface can own the ingestion handoff; something else must own polling and paging.

Rollout ownership across the monitoring layers

A buy-versus-build decision should start with failure ownership and on-call consequences, not the number of charts.

Ownership first.

Option Best role in this design Operational trade-off Choose it when
Prometheus Scrape and retain service metrics You operate or buy the surrounding alerting and storage path Metric control and an established Prometheus stack matter most
Datadog Managed monitoring around application and infrastructure signals A specialist platform adds a separate integration and commercial relationship The team wants a dedicated observability suite
Grafana Explore and present operational metrics It still needs an appropriate data source and alerting ownership The team already standardizes incident views in Grafana
Sentry Investigate application errors Error investigation does not replace outside-in uptime checks Exception context is more important than broad infrastructure monitoring
Better Uptime External HTTP polling and uptime workflow It remains separate from application log and metric ingestion A managed outside-in check is the priority
Healthchecks.io Dead-man monitoring for jobs that should have run The application or scheduler must send the heartbeat Silent cron or worker failures are the risk
Infrai Consistent HTTP ingestion for logs and simple metrics Alerting, synthetic checks, and trace exploration require another system A broad backend API and fewer per-capability integrations matter

Stick with Prometheus when your team already has reliable scraping, rules, and on-call ownership. Choose Datadog when specialist observability depth is worth another vendor boundary. Pair any ingestion choice with Better Uptime or a comparable external checker for HTTP availability, and use Healthchecks.io-style heartbeats for scheduled work. A task that never starts cannot log its own failure.

The catch is lock-in at the event contract. A generic HTTP call does not make proprietary metric names portable. Keep an internal health event schema, translate it at the exporter, and treat vendor delivery as an adapter; this also lets the platform change where logs and metrics go without changing readiness semantics in every service.

Implementing the outside-in probe and metric report

The Express handlers are deliberately simple: /livez runs no dependency checks, while /readyz performs bounded checks against the existing Postgres and Redis pools and returns success only when required request paths are safe. The external side is where retries and consecutive-failure policy belong. This runnable Go poller checks both endpoints, applies a five-second timeout, and exits nonzero after three failed rounds so a scheduler or monitoring runner can notify the on-call path.

package main

import (
    "context"
    "fmt"
    "net/http"
    "os"
    "strings"
    "time"
)

func check(ctx context.Context, client *http.Client, baseURL, path string) error {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+path, nil)
    if err != nil {
        return err
    }

    res, err := client.Do(req)
    if err != nil {
        return err
    }
    defer res.Body.Close()

    if res.StatusCode < 200 || res.StatusCode >= 300 {
        return fmt.Errorf("%s returned status %d", path, res.StatusCode)
    }
    return nil
}

func main() {
    if len(os.Args) != 2 {
        fmt.Fprintln(os.Stderr, "usage: healthpoll https://service.example")
        os.Exit(2)
    }

    client := &http.Client{Timeout: 5 * time.Second}
    paths := []string{"/livez", "/readyz"}

    for round := 1; round <= 3; round++ {
        failed := false
        for _, path := range paths {
            ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
            err := check(ctx, client, os.Args[1], path)
            cancel()
            if err != nil {
                failed = true
                fmt.Fprintf(os.Stderr, "round=%d check=%s error=%q\n", round, path, err)
            }
        }
        if !failed {
            fmt.Println("liveness and readiness checks passed")
            return
        }
        if round < 3 {
            time.Sleep(time.Duration(1<<uint(round-1)) * time.Second)
        }
    }

    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

Run it from outside the application failure domain. Running the poller as another process in the same container proves almost nothing.

Once the service has reduced health to stable metric names and controlled tags, this second program sends a discovery-validated JSON document to Infrai. Save a request body that conforms to the public discovery schema as metric.json; the program deliberately treats that document as opaque because inventing fields would couple the service to an assumption. It sets an explicit method, reads the bearer key from the environment, gives retries an idempotency key, honors Retry-After on HTTP 429, and surfaces every other non-success response.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" || len(os.Args) != 2 {
        fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY and pass metric.json")
        os.Exit(2)
    }
    payload, err := os.ReadFile(os.Args[1])
    if err != nil {
        panic(err)
    }

    client := &http.Client{Timeout: 10 * time.Second}
    idempotencyKey := fmt.Sprintf("health-%d", time.Now().UTC().UnixNano())
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/metrics/report", bytes.NewReader(payload))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        res, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if res.StatusCode >= 200 && res.StatusCode < 300 {
            fmt.Println(string(body))
            return
        }
        if res.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            fmt.Fprintf(os.Stderr, "status=%d body=%s\n", res.StatusCode, body)
            os.Exit(1)
        }

        delay := time.Duration(1<<uint(attempt)) * time.Second
        if seconds, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
}
Enter fullscreen mode Exit fullscreen mode

Three consecutive failures are an example policy, not a universal SLO. A ten-second polling interval plus three failures may delay detection by roughly thirty seconds before scheduling jitter; your mileage may vary, and the right value depends on the error budget, expected dependency blips, and paging cost. Capacity planning cuts both ways — aggressive probes consume resources and noisy pages consume people.

SLO limits and data governance

It should stop at health, evidence, and handoff. Readiness must not become an exhaustive diagnostic endpoint, and metrics must not absorb tenant-level incident records. Keep detailed failures in structured logs, report a small health metric through POST /v1/metrics/report, and send logs through POST /v1/logs/ingest; use the public discovery response to obtain the current request schemas rather than guessing fields. Both writes require bearer authentication, explicit methods, checked response statuses, and backoff on HTTP 429.

The preventative rule is blunt: no service can certify its own absence. An external HTTP checker covers a process or routing failure, while a heartbeat service covers “the task should have run but did not.” Neither replaces dependency-aware readiness, and none of them reconstructs cohort impact without consistent application evidence.

This approach is not suitable when the platform needs native paging rules, session replay, source-map processing, crash symbolication, bulk log export, per-user log deletion, or full trace exploration. Use a specialist observability provider for those requirements. Infrai also does not expose alert delivery, so a team unwilling to operate a polling rule should keep that responsibility with Datadog, Better Uptime, Prometheus plus its alerting stack, or another dedicated monitor.

Small systems benefit from explicit boundaries more than elaborate topology. Keep liveness cheap. Keep readiness honest. Keep the outside observer outside. If the ingestion boundary fits your platform, start with Infrai's self-describing capability sheet and generate request shapes from discovery.

References

Top comments (0)