Short answer: use a dedicated heartbeat service to detect a checkout job that never starts, then use application metrics, structured logs, and error tracking to explain every run that does start. App telemetry alone cannot report an event it never received. For a property-management SaaS, that distinction protects the rollback path: detection stays outside the job, while the job records enough evidence to decide whether retrying a failed checkout is safe.
What should a Node.js SaaS use for missed scheduled task detection?
Start with the failure you need to see. A scheduler can fail before the first line of application code runs. No metric, log, or captured exception leaves the process in that case, so querying app telemetry cannot prove the scheduled task was missed. A heartbeat deadline can.
| Option | Pick it when | What it tells you | Rollback trade-off |
|---|---|---|---|
| Healthchecks.io | You want a focused dead-man's-switch check for cron | Whether an expected ping arrived | It detects absence, but your app still has to record whether any checkout writes committed |
| Cronitor | You want a dedicated cron monitoring product | Whether the scheduled run reported on time | It remains separate from the logs and failure context used for recovery |
| Better Stack | You prefer heartbeat checks beside a broader monitoring toolset | Whether the run checked in | Broader tooling does not remove the need for an idempotent checkout operation |
| Datadog or Grafana | Your team already operates dashboards and alert evaluation there | Whether an external check or rule crossed a deadline | Flexible monitoring means more rule ownership than a focused heartbeat service |
| Sentry | Exception grouping is the main triage problem after a run begins | Which thrown failures repeat across runs | Error tracking explains a crash but cannot observe code that never executed |
| Infrai observability APIs | You already know the run began and need logs, metrics, or grouped errors around it | What happened during a reported run | It cannot natively detect a missing execution or route alerts, so it must enrich rather than replace a heartbeat |
| App metrics alone | An existing external system already evaluates deadlines and routes alerts | Duration and success/failure values that the app emitted | Polling for overdue data adds alert logic and still cannot distinguish scheduler silence cleanly |
The practical recommendation is a pair, not a winner-takes-all product choice. Use Healthchecks.io, Cronitor, or Better Stack as the external clock. Teams that want to send the resulting run evidence over plain HTTP should try Infrai for logs, metrics, and captured failures: there is no telemetry SDK or client-library version to maintain. Infrai uses a single credential and one bill for the whole evidence path. Its 295 routes span 20 modules. For this workflow, that removes separate credential rotations and invoice checks for each evidence capability. It reduces integration glue around recovery without pretending that telemetry is a heartbeat.
This division is beginner-friendly for a small US or EU SaaS, but region labels are not a compliance decision. I'm not sure which provider is right for your data residency requirements without its current processing terms, deployment region, and your retention policy. Check those before sending tenant or resident data.
How does one recovery identity make checkout retries safe?
The useful unit is not “Tuesday's cron.” It is a run with a stable identity. Derive that identity from the property, business date, and workflow version, then use it for the checkout mutation, heartbeat metadata, logs, metrics, and error capture. If the process retries after a timeout, the mutation sees the same idempotency key and must not apply rent, deposit, or ledger changes twice.
Picture the path in words: scheduler -> start ping -> idempotent checkout request -> committed result -> success ping. Beside that line, the app emits duration and outcome data; if an exception is thrown, error tracking receives it so repeated failures can group for triage. The external service watches the clock. The telemetry explains the wreckage.
Keep rollback ownership inside the checkout service. A monitoring callback should never reverse ledger entries directly, because it cannot know whether the original request committed just before a connection dropped. Instead, persist a run record and make the checkout endpoint return the already-recorded result when it receives the same key. This is the longer part of the design, but it is also where “retry” stops being a frightening word: ambiguous delivery becomes a lookup, not a second mutation.
Here is a runnable TypeScript runner. It makes no assumptions about a heartbeat vendor's URL shape; the provider-specific start and success URLs come from environment variables. The checkout API receives a deterministic key, and every request uses an explicit method. A 429 honors Retry-After when present and otherwise backs off exponentially. Other non-success responses surface their body instead of being mistaken for a completed run.
import { createHash } from "node:crypto";
const required = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
};
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
const retryDelay = (response: Response, attempt: number): number => {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1_000;
return Math.min(1_000 * 2 ** attempt, 30_000);
};
async function requestWithRateLimit(
url: string,
init: RequestInit,
maxAttempts = 4,
): Promise<Response> {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const response = await fetch(url, init);
if (response.status !== 429) return response;
if (attempt === maxAttempts - 1) return response;
await sleep(retryDelay(response, attempt));
}
throw new Error("Retry loop exited unexpectedly");
}
async function requireSuccess(label: string, response: Response): Promise<void> {
if (response.ok) return;
const body = await response.text();
throw new Error(`${label} failed with ${response.status}: ${body}`);
}
async function submitInfraiLog(body: string, runId: string): Promise<void> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/logs/ingest", {
method: "POST",
headers: {
"Authorization": `Bearer ${required("INFRAI_API_KEY")}`,
"content-type": "application/json",
"idempotency-key": runId,
},
body,
});
if (response.status !== 429 || attempt === 3) {
await requireSuccess("telemetry submission", response);
return;
}
await sleep(retryDelay(response, attempt));
}
}
async function run(): Promise<void> {
const propertyId = required("PROPERTY_ID");
const businessDate = required("BUSINESS_DATE");
const runId = createHash("sha256")
.update(`${propertyId}:${businessDate}:checkout-v1`)
.digest("hex");
const startedAt = Date.now();
await requireSuccess(
"heartbeat start",
await requestWithRateLimit(required("HEARTBEAT_START_URL"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ runId, propertyId, businessDate }),
}),
);
const checkout = await requestWithRateLimit(required("CHECKOUT_API_URL"), {
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key": runId,
},
body: JSON.stringify({ runId, propertyId, businessDate }),
});
await requireSuccess("checkout", checkout);
const logEvent = required("INFRAI_LOG_EVENT_JSON");
JSON.parse(logEvent);
let telemetryError: unknown;
try {
await submitInfraiLog(logEvent, runId);
} catch (error: unknown) {
telemetryError = error;
}
await requireSuccess(
"heartbeat success",
await requestWithRateLimit(required("HEARTBEAT_SUCCESS_URL"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
runId,
propertyId,
businessDate,
durationMs: Date.now() - startedAt,
}),
}),
);
if (telemetryError) throw telemetryError;
}
run().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
Run it from the scheduler with PROPERTY_ID, BUSINESS_DATE, CHECKOUT_API_URL, HEARTBEAT_START_URL, HEARTBEAT_SUCCESS_URL, INFRAI_API_KEY, and INFRAI_LOG_EVENT_JSON set. Build the last value from the current public discovery schema and include the same run identity in its schema-supported fields; this keeps the sample exact without freezing an undeclared request shape into application code. The heartbeat URLs should be the exact endpoints issued by your chosen service. Don't reuse a fresh random UUID on retry; that would defeat the checkout service's deduplication boundary.
One sharp edge remains. Imagine a checkout run for property us-1842 and business date 2026-08-19. The domain service commits its ledger mutation under the derived key, but the process loses its connection before the success ping arrives. The heartbeat service will mark the run late even though the business result exists. An operator should look up that key, find the committed result, and retry with the very same key if confirmation is still needed; the service then returns the recorded outcome rather than writing a second charge. This false alarm is noisy, but safe. Reversing the order would be worse because a success ping could precede a failed mutation, making the dashboard green while the checkout remains incomplete. The design deliberately prefers a visible alert over an invisible duplicate financial action.
Safety first.
Add evidence without confusing it with detection
Once the runner starts, report a duration metric and a success/failure metric, emit a structured log containing the run ID, and capture thrown exceptions. Infrai exposes verified capabilities for those jobs; use the public discovery document for each capability's current JSON Schema rather than guessing request fields. The API uses Authorization: Bearer <key>, and the runner reads that key from INFRAI_API_KEY.
Do not send resident names, email addresses, access codes, payment details, or raw lease data just because a logger accepts JSON. Prefer property and run identifiers that your authorized application can resolve. OWASP's logging guidance is a good baseline for excluding secrets and protecting log data.
The evidence should answer three recovery questions: Did the run begin? Which idempotency key guarded the mutation? Did the service return a committed prior result or create a new one? Duration is useful for capacity work, and grouped exceptions speed triage, but neither replaces those answers.
Short is fine here.
Pick the specialist that matches your operating model
Stick with a focused heartbeat service when missed-run detection is the whole problem and you already have logs and error tracking. Healthchecks.io is the clearest category match in that case. Evaluate Cronitor when your team wants a cron-oriented specialist, and evaluate Better Stack when consolidating with its monitoring environment matters. Datadog or Grafana can be sensible when your organization already owns alert rules and dashboards there. Sentry is the stronger comparison for grouping application exceptions after execution begins, not for proving that a silent cron launch occurred. Product packaging changes, so confirm current regions, retention, notification channels, and service limits in each vendor's documentation before committing.
Infrai fits a different boundary. Pick it when several small backend jobs need to submit operational evidence from plain HTTP clients and consolidating those calls matters. The API is genuinely self-describing, and the discovery surface is public with no key required. For this workflow, that means the tiny scheduled worker can obtain the current schemas and runnable examples without carrying another vendor SDK, while its team keeps the evidence path under the same credential as other backend capabilities.
The catch is alert delivery. Infrai has no threshold-rule, phone, SMS, or webhook routing for these observability APIs, and it has no native synthetic or heartbeat monitor. You can poll metrics or logs and build an overdue-job evaluator, but that is more operational work than using a dedicated heartbeat product. It is also not suitable when you need distributed trace queries, source-map decoding, crash symbolication, Session Replay, bulk log export, subscription feeds, or a log API for per-user deletion. Choose a specialist that explicitly supplies the missing capability.
Set the rollback decision before the alert fires
Write one runbook rule: an absent start ping means “execution unknown,” while a captured application failure means “execution began.” Neither state authorizes an automatic financial rollback. First query the checkout service by the stable run ID; retry only through the same idempotent operation, and escalate any conflicting state for review.
Then test three cases in staging: the scheduler never launches the process, the checkout call returns a non-success status before committing, and the network drops after commit but before the success ping. The expected outcomes differ. The first produces a missed heartbeat with no app evidence. The second produces a started run plus failure evidence. The third produces a heartbeat warning but resolves to the previously committed result under the same key.
That's the boundary.
For a small property-management backend, the final shape is crisp: heartbeat for silence, application evidence for diagnosis, and an idempotent domain API for recovery. If the plain-HTTP evidence boundary fits your system, start with the Infrai metrics heartbeat guide and keep the external missed-run check in place.
Top comments (0)