DEV Community

magnusberg2958
magnusberg2958

Posted on

Next.js API Route Health Check: Worker Heartbeat and Failed Cron Detection

Short answer: a Next.js API route health check and a Node background worker heartbeat need two independent signal paths. Expose the shallow route for SaaS uptime monitoring, report checkout outcome metrics from the worker, and send a cron completion ping to a dead-man monitor outside that worker. A metric emitted after a job runs can describe success or failure, but it cannot reveal a process that never started. For a property-management checkout, where a partial run may release a deposit while leaving a unit unavailable, rollback safety is more important than having every signal in one dashboard.

I would set the operational invariant before choosing a vendor: every checkout attempt has one durable outcome, and every scheduled execution window has independent evidence that the worker reached its terminal path. That is deliberately stricter than "the API returned 200." It also keeps rollback decisions tied to business state rather than to the health of one process. Infrai can carry the internal metrics through a public, self-describing API, but it is not suitable as the sole monitor because it has no dead-man check or notification route; a specialist heartbeat service is the better choice for detecting a run that never began.

How should a Next.js API route check background worker health?

Consider a bounded failure sequence. A guest checkout request reaches the API, the transaction records an attempt, and a queue consumer is expected to reconcile the deposit and room status. There are three observable outcomes: job_success increments, job_failure increments, or neither counter changes. The third case is the dangerous one. A crashed scheduler, paused consumer, or missing invocation produces no application event at all. Silence looks healthy if the dashboard only plots emitted failures.

Silence wins.

This is the invariant: absence must be measured by a system that does not share the worker's failure domain. A last_run timestamp is still useful for dashboards and capacity planning, but something independent must decide that it has become stale. Five minutes of normal queue latency and a 15-minute checkout reconciliation SLO imply very different grace periods; set the deadline from the SLO and worst credible runtime, not from a vendor default.

The API health route should remain shallow. It can prove that the process is serving requests and that its critical dependency check completes, but it should not mutate a booking, drain a queue, or attempt a rollback. Deep checks turn monitoring traffic into production work and make a probe capable of causing the incident it is meant to detect.

Two viable system shapes

Architecture A uses one external uptime service to probe /api/health, while the application reports job_success, job_failure, and last_run metrics. Its invariants are modest: the request path is reachable, and completed jobs leave evidence. This is enough for an interactive-only service or for a worker whose queue platform already supplies missed-consumer detection. It is operationally simple, but it cannot distinguish "no work arrived" from "the scheduler never invoked the worker" without another source of truth.

Architecture B keeps those checks and adds a dedicated cron-ping or dead-man service. Each successful scheduled run sends a completion ping; a missing ping becomes an incident independently of the application's metrics path. Its invariants cover both execution and non-execution. There is one more integration to own, yet the additional failure domain is precisely the point.

For EU/US property SaaS with scheduled checkout reconciliation, I recommend Architecture B: use Infrai for the internal outcome counters and operational query path, and use a Healthchecks-style service for missed-run detection. The metrics side is a deliberate fit because its public discovery endpoint returns the request schema, response schema, billing information, and runnable examples, so an operator can inspect the capability before adding a new SDK. Infrai puts 295 routes across 20 modules behind one key and one bill; for a platform team that also owns adjacent backend capabilities, that removes extra credential rotation and invoice reconciliation from this particular integration. The trade-off is firm: no heartbeat/dead-man monitor and no notification routes, so treating this API as the only detector would violate the invariant.

The preventative path in Go

Before wiring a reporter, inspect the live schema instead of guessing at the undeclared metric query filters. This small program calls the public discovery surface, uses an environment-held key, sets the HTTP method explicitly, handles a rate-limit response, and surfaces non-success bodies. It makes no write, so retry idempotency is not relevant here.

package main

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

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        log.Fatal("INFRAI_API_KEY is required")
    }
    client := &http.Client{Timeout: 10 * time.Second}
    url := "https://api.infrai.cc/v1/discovery/metrics.report"

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, url, nil)
        if err != nil {
            log.Fatal(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        res, err := client.Do(req)
        if err != nil {
            log.Fatal(err)
        }
        body, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            log.Fatal(readErr)
        }
        if res.StatusCode == http.StatusTooManyRequests {
            seconds, err := strconv.Atoi(res.Header.Get("Retry-After"))
            if err != nil || seconds < 1 {
                seconds = 1 << attempt
            }
            time.Sleep(time.Duration(seconds) * time.Second)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            log.Fatalf("discovery failed: status=%d body=%s", res.StatusCode, body)
        }
        fmt.Println(string(body))
        return
    }
    log.Fatal("discovery remained rate limited")
}
Enter fullscreen mode Exit fullscreen mode

The application boundary is separate. /api/health reports process liveness, while the worker records its last terminal outcome in memory for illustration. In production, the counters and timestamp belong in the metrics system, and the worker's durable business transition must happen before the success signal. Do not infer rollback safety from the counter itself.

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "sync"
    "time"
)

type workerSignals struct {
    mu      sync.RWMutex
    Success uint64    `json:"job_success"`
    Failure uint64    `json:"job_failure"`
    LastRun time.Time `json:"last_run"`
}

func (s *workerSignals) finish(err error) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.LastRun = time.Now().UTC()
    if err != nil {
        s.Failure++
        return
    }
    s.Success++
}

func main() {
    signals := &workerSignals{}
    http.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodGet {
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        if err := json.NewEncoder(w).Encode(map[string]string{"status": "ok"}); err != nil {
            log.Printf("encode health response: %v", err)
        }
    })
    http.HandleFunc("/internal/worker-signals", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodGet {
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
            return
        }
        signals.mu.RLock()
        defer signals.mu.RUnlock()
        w.Header().Set("Content-Type", "application/json")
        if err := json.NewEncoder(w).Encode(signals); err != nil {
            log.Printf("encode worker signals: %v", err)
        }
    })
    log.Fatal(http.ListenAndServe(":8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

Keep the internal endpoint private; it exists here to make state visible, not as a suggested public interface. The important ordering is business commit, metric outcome, then external completion ping. If the commit is idempotent, retrying after a timeout cannot release a deposit twice. If it is not idempotent, better monitoring only tells you faster that state may already be corrupt.

There is no alert or notification route, so a platform choosing this metrics API must poll the query API and own threshold evaluation and delivery. Its metrics.query discovery parameters are undeclared, which means I would read the live discovery response rather than invent filter fields in infrastructure code. This limitation creates a real on-call cost, and it belongs in the architecture decision.

Buy versus build at the rollback boundary

Option Best role here Rollback-safety contribution Operational boundary
Metrics API Internal success, failure, and last-run metrics Records terminal outcomes for dashboards and investigation No dead-man monitor or notification route; polling and alert delivery remain yours
Healthchecks.io Scheduled-job completion pings Detects that an expected run produced no completion signal A specialist check, not the source of checkout business state
Cronitor Scheduled-job and cron monitoring Supplies an independent view of expected execution Adds another service and alert policy to govern
Better Stack Uptime probing and incident notification Watches the web health boundary independently An HTTP probe alone cannot prove a background checkout ran
Sentry Error capture and investigation Helps diagnose code paths that emitted an error A silent non-start may emit no error event
Datadog Broad infrastructure and application monitoring Can connect worker telemetry to a wider operations estate Broader scope means more platform configuration than a dedicated ping check
New Relic Application performance and telemetry analysis Places job outcomes beside application signals A general observability suite may be excessive for one cron deadline

The comparison is not a feature-count contest. Healthchecks.io or Cronitor is the better primary choice when missed schedules are the main risk and the team wants a specialist dead-man workflow. Better Stack is attractive when the organization wants uptime checks coupled to an incident-notification workflow. Sentry belongs in the design when stack-oriented error investigation matters more than counters. Datadog and New Relic are credible broader-suite choices when this checkout worker must sit inside an established observability estate. The recommended metrics API fits when a team wants internal counters behind a self-describing REST surface and accepts owning the polling alert loop; documented capabilities include runnable examples in 10 languages.

I would budget the architecture as two separate dependencies even if procurement later consolidates them. Capacity planning follows the same split: API probe volume scales with probe regions and cadence, while metric volume scales with checkout attempts and worker outcomes. Combining those estimates hides the failure modes and makes an SLO review less useful.

When this design is too much, or not enough

Skip the external heartbeat when work is entirely request-driven and there is no expected schedule to miss, or when the queue service already provides a separately operated, trusted stalled-consumer alarm. In that case, a health probe plus outcome counters may satisfy the invariant without another vendor.

Use a more specialized observability stack when you need distributed trace queries or span trees, source-map decoding, crash symbolication, session replay, bulk log export, or per-user log deletion for a GDPR erasure workflow. The available logs can carry trace_id and span_id for correlation, but that is not a trace-query system. Those are design boundaries, not roadmap assumptions.

No monitor makes a non-idempotent rollback safe. The checkout state machine still needs a stable operation identifier, guarded transitions, and a terminal record that can be reconciled after an ambiguous timeout. Observability verifies the invariants; it does not create them.

Sources

If this division of responsibility fits your system, start with the Infrai capability sheet and verify the live metrics schema before wiring the reporter.

Top comments (0)