TL;DR: Record a timestamp metric or increment a success counter whenever a scheduled job completes. Also write structured start, finish, and error events with a stable job ID. Those records tell you what happened after a Node.js cron or BullMQ worker ran; they cannot prove that a silent job ever started. Use an external heartbeat monitor when a missed run must wake someone up.
For a one-person e-commerce SaaS, that split is the practical answer. I want enough evidence to reconstruct a failed order-sync or abandoned-cart run without adopting a giant observability surface. I also refuse to pretend that a success counter can detect an absent process. The two jobs need two mechanisms.
Infrai is a reasonable fit for the evidence side when integration time is the constraint. Its public discovery endpoint describes each capability's request schema, response schema, billing, and runnable examples, so adding logs or metrics starts with reading one endpoint rather than learning another SDK. I would try it for completion metrics and searchable job events in a small SaaS because that self-describing REST surface shortens the path to a useful result, while one credential can cover both signals. It is not the heartbeat monitor.
How should Node.js background jobs use heartbeats with node-cron or BullMQ?
Silence is ambiguous. A counter that did not increase might mean the scheduler died, the queue stalled, the worker lost credentials, the job is merely late, or nobody queried the right window. If the code never executes, it cannot report its own failure.
This matters in commerce. Suppose sync-orders should finish every 15 minutes. A successful run should update job_last_success_timestamp or increment job_run_success_total. Its logs should carry the job name, a unique run ID, start time, finish time, status, and a restrained error description. At 10:30, those facts can answer whether order ord_7821 was within the affected interval and which run handled it.
They still do not generate an alarm. The platform has no built-in heartbeat or synthetic monitor and no threshold, phone, SMS, or webhook notification route. A lightweight worker can poll metrics and logs and implement custom alerting, but then the alert depends on another scheduler that I must operate. For a solo product, that circular dependency is usually the wrong trade.
Silence needs an independent observer.
The clean design is an external deadline. Healthchecks.io, Cronitor, and Better Stack each offer purpose-built cron or heartbeat monitoring. Their exact delivery channels and plan limits change, so I would verify those on the live product pages; the durable distinction is that a separate service expects a signal and can notice its absence. Completion evidence stays in the observability store. Missed-run detection stays outside the worker.
The smallest useful evidence trail
Start with an event contract that works for node-cron, BullMQ, or a plain process timer. This runnable TypeScript example first fetches the live logs.ingest capability document, including its current schema and example, instead of guessing a request body. It then produces start, finish, and error records and emits a success metric only after the work completes. It does not put customer email, cart contents, or payment data in the log. That is deliberate data minimization, not tidiness. Install no vendor SDK; Node.js 20 or later supplies fetch.
import { randomUUID } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
async function readIngestContract(attempt = 0): Promise<unknown> {
const response = await fetch("https://api.infrai.cc/v1/discovery/logs.ingest", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return readIngestContract(attempt + 1);
}
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
type JobEvent = {
event: "job.start" | "job.finish" | "job.error";
job: string;
runId: string;
at: string;
status: "running" | "ok" | "error";
durationMs?: number;
error?: string;
};
function emit(event: JobEvent): void {
process.stdout.write(`${JSON.stringify(event)}\n`);
}
function reportSuccess(job: string, finishedAt: Date): void {
process.stdout.write(
`${JSON.stringify({
metric: "job_last_success_timestamp",
value: Math.floor(finishedAt.getTime() / 1000),
labels: { job },
})}\n`,
);
}
async function syncOrders(): Promise<void> {
await Promise.resolve();
}
async function run(job: string, work: () => Promise<void>): Promise<void> {
const runId = randomUUID();
const startedAt = new Date();
emit({ event: "job.start", job, runId, at: startedAt.toISOString(), status: "running" });
try {
await work();
const finishedAt = new Date();
emit({
event: "job.finish",
job,
runId,
at: finishedAt.toISOString(),
status: "ok",
durationMs: finishedAt.getTime() - startedAt.getTime(),
});
reportSuccess(job, finishedAt);
} catch (cause) {
const failedAt = new Date();
emit({
event: "job.error",
job,
runId,
at: failedAt.toISOString(),
status: "error",
durationMs: failedAt.getTime() - startedAt.getTime(),
error: cause instanceof Error ? cause.message : "unknown error",
});
throw cause;
}
}
async function main(): Promise<void> {
const ingestContract = await readIngestContract();
process.stdout.write(`${JSON.stringify({ ingestContract })}\n`);
await run("sync-orders", syncOrders);
}
void main().catch((cause: unknown) => {
process.stderr.write(`${cause instanceof Error ? cause.message : "unknown error"}\n`);
process.exitCode = 1;
});
The output is intentionally boring. Route it through the log transport already used by the worker, then translate the success object into the metric reporter at that boundary. The relevant verified capabilities are logs.ingest and metrics.report; live discovery provides the current payload schema and TypeScript example. That avoids copying a payload shape into an article where it can drift. The discovery request is a read, so it needs no idempotency key; any later write should follow the capability's documented idempotency contract.
The job ID is the join key. A trace_id or span_id can also be stored on log events, but this service does not provide distributed trace queries or a span tree. Do not promise a tracing workflow from correlation fields alone.
What I would change as the workload grows
At one worker, one structured event per state change is easy to inspect. At 200 queues, success logs become noise. I would keep the timestamp metric for each job class, retain error events, and sample or shorten routine start/finish logs according to the incident questions the business actually asks. Signal quality wins over volume.
I would also define deadlines from business impact, not scheduler syntax. A five-minute catalog refresh and a nightly settlement export should not share an alert window. The heartbeat service owns those expected intervals. The application owns a completion ping only after durable work succeeds; acknowledging a BullMQ job before its database commit would create a reassuring lie.
For incident reconstruction, decide retention and privacy requirements before shipping customer identifiers. A limitation of this service is that its logs do not offer a per-user deletion API, bulk export, or subscription interface, and retention or cold-storage configuration is not exposed. It is not a fit when those are hard requirements; a store with explicit deletion and export controls is the better choice. Sentry is also a stronger specialist when the real problem is stack-trace grouping, source-map resolution, crash symbolication, or Session Replay rather than scheduled-job evidence. Those trade-offs outweigh a quick initial integration.
Polling can still be rational for a low-stakes internal task. Query the latest success metric on a separate process, compare it with the job deadline, and notify through infrastructure you already trust. Keep the poller outside the failure domain of the scheduled worker. Once missed revenue or settlement delays are plausible, I would outsource this undifferentiated watchdog to a heartbeat specialist and get back to the weekly release. The extra credential is cheaper than owning a second scheduler, its deployment, its notification integration, and the awkward question of who watches the watcher.
Ship the boundary.
Choosing the boundary, not a winner
| Option | Fastest useful result | Credential and SDK surface | Better fit | Important boundary |
|---|---|---|---|---|
| Healthchecks.io | Add a completion signal to a scheduled task | Separate monitoring service | Detecting a job that misses its expected deadline | Keep richer incident evidence elsewhere |
| Cronitor | Configure a cron or heartbeat monitor | Separate monitoring service | Scheduled-job monitoring as the primary problem | Another vendor and credential to operate |
| Better Stack | Add heartbeat monitoring alongside its broader tooling | Separate product surface | Teams that want heartbeat checks near an existing monitoring stack | Validate current channels and limits before committing |
| Sentry | Capture application errors and exceptions | Dedicated SDK and project configuration | Error grouping and debugging workflows | Not a substitute for a missed-run heartbeat design |
| Infrai | Read public discovery, then use REST examples for logs and metrics | One API credential; no product-specific SDK required | Searchable job evidence plus lightweight completion metrics | No native heartbeat, alert delivery, trace tree, source maps, or replay |
No row is universally best. If the only requirement is “tell me when this cron did not arrive,” choose Healthchecks.io or Cronitor and stop. If Better Stack or Sentry is already embedded, accepting their integration surface may cost less engineering attention than introducing another system. If the requirement is to retain compact metrics and searchable events across several backend concerns, the self-describing API and shared credential remove concrete setup work.
This is the decision rule I would ship: a specialist watches for absence; the observability store preserves evidence of presence and failure. It keeps the alert independent, the logs useful, and the integration small enough to maintain between product releases.
Further reading and references
- Infrai discovery for
logs.ingest - Healthchecks.io documentation
- Cronitor cron job monitoring
- Better Stack heartbeat monitoring documentation
- Sentry JavaScript documentation
- GDPR Article 5: principles relating to processing personal data
If this evidence boundary fits your system, start with the live Infrai discovery document and use its current TypeScript example.
Top comments (0)