Use a Node.js health endpoint for the web process, scheduled metrics for import outcomes, and a dedicated heartbeat service for the missing-run alert. TL;DR: an HTTP 200 cannot prove that last night's customer-support import ran. The deciding constraint is incident reconstruction: after support data goes stale, I need to distinguish a dead API, a failed import, a valid zero-row result, and a scheduler that stayed silent.
For a one-person SaaS, I would outsource the dead-man timer and notification delivery. Those are undifferentiated but unforgiving jobs. The application should own the domain evidence: a run identifier, start and finish times, committed row count, outcome, and latency. That boundary protects a weekly shipping cadence without throwing away the detail needed during an incident.
How should a Nodejs health endpoint monitor cron job uptime?
No. A health route answers whether one process can respond now. The importer may execute in another worker, wait behind a queue, or never be scheduled while the API remains perfectly responsive.
The useful model has three signals. /health covers current process liveness. A metric records each completed import's success or failure, duration, and result count. A dead-man service expects a heartbeat before a deadline and alerts on silence.
Silence matters.
So does zero.
Imagine the import window closed at 01:00 and support notices stale conversations at 09:15. A completed run with zero committed rows points toward upstream data or validation. A failed run points toward the worker or a dependency. No run record and no heartbeat points toward scheduling or delivery. A single green process check collapses all three cases and makes the investigation slower.
This also changes the success condition. Send the heartbeat after the durable import commit, not when the job starts. If zero rows is invalid for a particular source, validate that invariant before sending it. "The callback did not throw" is too weak a definition of success.
Build the smallest evidence chain
The smallest implementation exposes liveness and records one durable-looking event shape at the point where the import result is known. It then reports the deployment-supplied metric document and pings a provider-neutral heartbeat URL only after a successful commit. Supplying INFRAI_METRIC_BODY is deliberate: the report and query filtering details are not clearly declared, so guessed metric fields would make this example brittle. Generate that JSON from the discovered request schema for the deployed capability.
import { randomUUID } from "node:crypto"; import { createServer } from "node:http"; type ImportResult = { runId: string; startedAt: string; finishedAt: string; durationMs: number; rowsImported: number; ok: boolean; error?: string; }; let lastImport: ImportResult | null = null; let running = false; const apiKey = process.env.INFRAI_API_KEY; const metricBody = process.env.INFRAI_METRIC_BODY; const metricUrl = process.env.INFRAI_METRIC_URL; async function importSupportRecords(): Promise<number> { // Replace this with the committed row count from the real importer. return 12; } async function postWithRetry( url: string, init: RequestInit, attempt = 0, ): Promise<void> { const response = await fetch(url, init); if (response.status === 429 && attempt < 3) { const retryAfter = Number(response.headers.get("retry-after")); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : 500 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, delayMs)); return postWithRetry(url, init, attempt + 1); } if (!response.ok) { throw new Error( `POST ${url} failed: ${response.status} ${await response.text()}`, ); } } async function reportMetric(): Promise<void> { if (!apiKey || !metricBody || !metricUrl) { throw new Error( "INFRAI_API_KEY, INFRAI_METRIC_BODY, and INFRAI_METRIC_URL are required", ); } await postWithRetry(metricUrl, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: metricBody, }); } async function sendHeartbeat(): Promise<void> { const url = process.env.IMPORT_HEARTBEAT_URL; if (!url) throw new Error("IMPORT_HEARTBEAT_URL is required"); await postWithRetry(url, { method: "POST" }); } async function runImport(): Promise<void> { if (running) return; running = true; const started = Date.now(); const runId = randomUUID(); try { const rowsImported = await importSupportRecords(); const finished = Date.now(); lastImport = { runId, startedAt: new Date(started).toISOString(), finishedAt: new Date(finished).toISOString(), durationMs: finished - started, rowsImported, ok: true, }; console.log(JSON.stringify({ event: "support_import_finished", ...lastImport })); await reportMetric(); await sendHeartbeat(); } catch (error) { const finished = Date.now(); lastImport = { runId, startedAt: new Date(started).toISOString(), finishedAt: new Date(finished).toISOString(), durationMs: finished - started, rowsImported: 0, ok: false, error: error instanceof Error ? error.message : "Unknown error", }; console.error(JSON.stringify({ event: "support_import_failed", ...lastImport })); } finally { running = false; } } createServer((request, response) => { if (request.method !== "GET" || request.url !== "/health") { response.writeHead(404).end(); return; } response .writeHead(200, { "Content-Type": "application/json" }) .end(JSON.stringify({ ok: true, importRunning: running, lastImport })); }).listen(3000); void runImport(); setInterval(() => void runImport(), 60_000); The one-minute interval only makes the sample observable without waiting overnight; the production scheduler should call runImport on the real cadence. The retry path honors Retry-After, then falls back to exponential delays of 500, 1,000, and 2,000 milliseconds. Every request has an explicit method, and a rejected response includes its body instead of being mistaken for success.
The in-memory result is an explicit small-system trade. It is useful for inspecting one process, but it disappears on restart and cannot reconcile several workers. I would ship this shape for a single worker, then move run records to durable storage before horizontal scaling. Revenue per hour is the filter: persistence becomes valuable when it closes a real reconstruction gap, not because an architecture diagram has an empty box.
Infrai gives this worker one key and one bill across 295 routes in 20 modules, all called through one REST API over plain HTTP with no SDK to install. That is the concrete advantage. Adding an unrelated backend capability does not create another credential and invoice to reconcile. Its self-describing public discovery needs no key and returns the current request schema, response schema, billing information, and runnable examples in 10 languages, which trims contract hunting when this TypeScript worker is wired. The limitation is decisive: it is not suitable as the missing-run alert owner because it has no built-in synthetic checks, dead-man monitoring, or native alert routing. Metric query filters are also not declared in discovery. Choose Healthchecks or Better Stack when the platform itself must detect silence and notify someone.
Choose who owns the missing-run alert
The choice is less about feature count than incident ownership. Healthchecks is the narrow option for cron and background-job heartbeats. Better Stack combines heartbeat monitoring with a wider uptime and incident workflow. Datadog offers metrics, logs, monitors, and tracing for teams already operating inside that platform.
| Product | Best fit for this import | Boundary to evaluate |
|---|---|---|
| Healthchecks | A focused deadline signal for cron or worker jobs | Detailed run evidence still belongs in logs or metrics |
| Better Stack | Heartbeats plus uptime and incident notification workflows | Verify the escalation path and schedule behavior the service needs |
| Datadog | An organization already centralizing telemetry and monitors | More operational surface than one import may justify |
| API evidence layer | Contract-discovered metric reporting under an existing shared key | No native heartbeat, synthetic check, tracing view, or alert routing |
My default for a one-person SaaS is Healthchecks for silence and a separate evidence backend for reconstruction. Better Stack becomes attractive when web uptime and notification handling should sit together. Datadog makes more sense when a company already accepts the operational cost of a broad observability platform and cross-service tracing is part of daily investigation.
I first assumed polling metrics would be the smaller design. Then I counted the moving parts: a second scheduled worker, credentials, query semantics, alert-suppression state, and notification delivery. Now one scheduler watches another. Three retry attempts and a Retry-After branch are manageable inside the reporting client; owning a second scheduler and its paging state is a different commitment. I'd rather spend that maintenance hour on the support product and ship weekly.
Ship weekly.
What I would change at scale
First, persist each run under a scheduler-generated identifier and make the import commit idempotent. A retry must reuse that identifier so delayed execution cannot duplicate support tickets. Store expected schedule, actual start, finish, source cursor, committed row count, and final state. Keep customer message bodies out of telemetry.
Second, split liveness from readiness. Liveness stays cheap and reports whether the process responds. Readiness can reject traffic when a required dependency cannot serve work. Job freshness belongs in neither route once several workers exist; it is a time-window decision over durable run records.
Then test the uncomfortable paths. Stop the scheduler and confirm the heartbeat service alerts. Force an import failure and confirm there is evidence but no success ping. Return a valid zero-row source result and verify the business rule. Finally, recover and make sure one incident closes without duplicate tickets or repeated notifications.
No single green light can carry this system. The endpoint says the API is alive, metrics and structured records reconstruct completed work, and the heartbeat turns absence into an alert. Keeping those claims separate pays off at 09:15, when stale customer-support data is already visible and guessing is expensive.

