DEV Community

CelesteRaine1783
CelesteRaine1783

Posted on

Node.js HTTP Health Checks with Polling: API Choice for Delivery Cost Attribution

When a media notification service misses a delivery, the simplest HTTP poller is not automatically the simplest system. The useful choice is a small, bounded request loop that records each probe as an event with a stable check identity, separates transport failure from an unhealthy response, and carries enough context to attribute the resulting cost to a service, region, or delivery path.

Short answer: use one standard HTTP client interface in Node.js, a fixed polling interval, an explicit timeout, and structured events for every attempt. Keep the probe endpoint boring. The hard part is not sending GET /health; it is deciding what a failed check means when a notification worker, an origin, and a downstream media provider can all be responsible.

What does a health poll actually tell you?

A poll is an observation, not a diagnosis. A connection timeout says that the observer did not receive a response in time. A 503 says that an HTTP server produced a response indicating temporary unavailability. A 200 only says that the endpoint returned that status; it does not prove that a queued video notification was accepted by its final destination.

That distinction matters in media operations because delivery failure has at least two different accounting paths. The monitoring system spends resources making the request, while the notification service may spend resources retrying the delivery. If the event schema collapses those into healthcheck_failed, the later cost report cannot tell whether repeated probes found an overloaded worker or merely observed a slow upstream.

I use a small event shape for the polling boundary:

event = {
    "check_id": "media-notification-api",
    "observed_at": "2026-10-28T08:15:00Z",
    "target": "https://status.example.invalid/health",
    "outcome": "timeout",  # ok, http_error, timeout, or transport_error
    "status_code": None,
    "elapsed_ms": 1500,
    "attempt": 3,
    "cost_scope": "notification-worker-eu-west",
}
Enter fullscreen mode Exit fullscreen mode

The timestamp, attempt number, and cost scope are more valuable than a long exception string. They let an operator group failures without guessing which retry generated them. OpenTelemetry describes logs as records that can carry timestamped event data and attributes; the same separation is useful here even if the implementation writes JSON to a local queue rather than adopting a complete telemetry stack.[1]

Which polling boundary keeps the signal honest?

There are three boundaries worth keeping explicit: request creation, response classification, and event export. A timeout belongs to the first two; an exporter outage belongs to the third. If the poller blocks until its telemetry destination recovers, it can turn an observability problem into a second outage.

The interval also needs a definition. “Poll every 30 seconds” can mean 30 seconds between starts or 30 seconds after the previous request finishes. For a service that is already slow, those definitions produce very different load. A start-to-start schedule is predictable but can overlap requests; a completion-to-start schedule avoids overlap but drifts during an incident. For a basic uptime check, I would bound one in-flight request per target and schedule the next attempt only after classification, then record the delay so the drift is visible.

The simplest request API is the one already accepted by the runtime and team: the platform fetch interface in a supported Node.js release, a small wrapper around it, or a maintained client with the same observable contract. The decision is not a contest over method names. It is about whether the client gives the poller a timeout, response status, elapsed time, and a failure category without hiding retries.

This is a boundary, not a verdict.

Boundary Useful fact to retain Failure that gets misread
Transport timeout or connection error “The application returned 500”
HTTP response status and bounded body classification “The network is down”
Scheduler planned and actual start time “The endpoint is randomly slow”
Export whether the event was accepted locally “No failures occurred”

Avoid a health endpoint that performs a full notification send. That makes a read-only probe consume the very delivery capacity it is measuring, and it makes cost attribution circular. A shallow liveness check can be paired with a separately scheduled readiness check that verifies required dependencies, with the two results kept distinct.

How should Node.js clients be compared without turning this into a catalog?

For this problem, a client comparison has only three useful questions: does it run in the intended Node.js environment, can the caller enforce a deadline, and are retries visible in the event stream? The client should not silently multiply a single scheduled poll into several billable requests unless that policy is explicit.

The built-in fetch API is attractive when the poller needs one request operation and the runtime already supplies it; its promise resolves to a response object, so the caller still has to classify HTTP status and enforce an application deadline.[2] Axios offers a Node.js and browser API with its own configuration surface, while got is a Node.js-focused HTTP client with options for request behavior.[3][4] Those are meaningful boundaries, not rankings: choosing one adds a dependency or a team convention, and neither choice determines whether the surrounding scheduler records accurate cost scope.

There is a real trade-off here. The built-in interface keeps the dependency graph small, but it leaves more policy in the wrapper. Axios can fit a team already using its interceptors, but that shared behavior can obscure which retry produced an event if the poller does not disable or record it. got exposes Node.js-oriented controls, but adopting it solely for a one-request probe is a poor fit for a small service that does not need those controls. None is suitable as a substitute for an endpoint contract or a cost model.

The wrapper should expose a deliberately small contract. The following is Python-shaped pseudocode because the important part is the state transition, not a copy-paste library recommendation; the production implementation can map the same contract to the chosen Node.js client.

def classify_probe(request, deadline_ms):
    started = monotonic_ms()
    try:
        response = request(timeout_ms=deadline_ms)
        elapsed = monotonic_ms() - started
        if 200 <= response.status < 400:
            outcome = "ok"
        else:
            outcome = "http_error"
        return {
            "outcome": outcome,
            "status_code": response.status,
            "elapsed_ms": elapsed,
        }
    except TimeoutError:
        return {
            "outcome": "timeout",
            "status_code": None,
            "elapsed_ms": monotonic_ms() - started,
        }
    except Exception:
        return {
            "outcome": "transport_error",
            "status_code": None,
            "elapsed_ms": monotonic_ms() - started,
        }
Enter fullscreen mode Exit fullscreen mode

The important limit is the deadline. Without one, a polling loop can accumulate stuck sockets and distort both availability and resource accounting. The response body should also be bounded or ignored unless the check requires a small machine-readable readiness field; health payloads are not a place to dump dependency diagnostics into every log record.

Where does cost attribution fail first?

The first failure is usually identity. A monitor might label every request healthcheck, while the service has separate notification classes for trailers, live-event alerts, and account messages. A second failure is retry ambiguity: a 15-second timeout followed by two client retries looks like three independent outages unless the event carries a poll identifier and attempt number. A third is cardinality: putting the full URL, request ID, and exception text into an unconstrained metric label creates a much larger bill without improving the decision.

For a media notification service, I would keep low-cardinality dimensions in metrics and put diagnostic detail in logs. service, environment, region, check_id, and cost_scope are reasonable dimensions if their sets are controlled. Request IDs and destination URLs belong in event fields with retention rules. The accounting question is then concrete: which scope paid for the probe, and which scope paid for the delivery retry? That extra context costs storage and processing, so a team that only needs a binary page should not pretend it needs every diagnostic field forever.

Do not infer attribution from latency alone. A fast 429 can be a capacity policy working as designed; a slow 200 can still leave a downstream delivery unconfirmed. Record the result that the endpoint promises, and define separately what “delivery accepted” means for the notification workflow.

A compact rollout that survives the first incident

Start with one target and one interval. In a staging environment, inject a timeout, a malformed response, a 503, and an exporter failure independently; the expected event categories should remain distinct. Then run the poller beside the existing monitor long enough to compare timestamps and duplicate counts, while keeping the new events out of paging until their false-positive rate is understood.

The operational defaults should be written down: deadline, interval, overlap policy, status range treated as healthy, retry policy, event retention, and ownership of the cost scope. Those values are part of the system contract. A future engineer should not have to reverse-engineer them from an alert at 03:00.

For migration, change one boundary at a time. First preserve the old signal while adding the new event fields; then switch dashboards to the classified outcome; finally enable alerts based on sustained failures rather than one missed poll. This sequence keeps a missing exporter event from masquerading as a recovered service and gives the accounting team a stable way to reconcile monitoring traffic with notification work.

The simplest API is therefore a narrow request call surrounded by explicit policy. Pick the client that fits the runtime, but make timeout, classification, scheduling, and cost identity your own contract. That is the part that remains understandable when the endpoint is slow and the invoice is not.

Sources

References:

  1. OpenTelemetry, “Logs signal concepts”: https://opentelemetry.io/docs/concepts/signals/logs/
  2. MDN Web Docs, “Window: fetch() method”: https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch
  3. Axios documentation, “Getting Started”: https://axios-http.com/docs/intro
  4. got documentation and repository: https://github.com/sindresorhus/got

Top comments (0)