Cron heartbeats in Node: detecting the job that never ran
A dead-man's switch for scheduled jobs on Infrai's metrics API, plus the /healthz endpoint it does not replace — and the response field that tells you when the last beat landed.
Two different things get called health monitoring, and conflating them is why a nightly job can be silently dead for a week. A /healthz endpoint answers “is this process able to serve right now” when something asks. A heartbeat answers “did the 03:00 invoice run finish” when nobody is asking. Infrai covers the second with POST /v1/metrics/report on the job side and a free GET /v1/metrics/query on the watcher side.
The awkward part is that absence has no event. Nothing gets written when a cron doesn’t fire, so a watcher can’t look for a failure — it has to notice a gap. Below is the mechanism that makes that gap visible, and the /healthz handler that belongs next to it rather than instead of it.
Which of the two you actually need
| Symptom you’re afraid of | What catches it | Where it runs |
|---|---|---|
| Process wedged, port open, requests hanging | /healthz with a real dependency check | Your load balancer or orchestrator |
| Container OOM-killed and restarting | Restart counter plus liveness probe | Your platform |
| Cron never fired (scheduler down, deploy dropped the entry) | Heartbeat with a staleness threshold | An external watcher |
| Job ran but did half the work | Heartbeat carrying a work count, not just a ping | The job itself |
The last row is the one people skip. A ping that says “I ran” is a weaker signal than a value that says “I ran and wrote 812 invoices”, and it costs the same to send.
The health endpoint
import express from "express";
import { setTimeout as delay } from "node:timers/promises";
const app = express();
const startedAt = Date.now();
async function checkDatabase() {
// Replace with your real pool: `await pool.query("select 1")`.
await delay(5);
return true;
}
app.get("/healthz", async (_req, res) => {
try {
const dbOk = await Promise.race([
checkDatabase(),
delay(750).then(() => { throw new Error("db check timed out"); }),
]);
res.status(dbOk ? 200 : 503).json({
status: dbOk ? "ok" : "degraded",
uptime_s: Math.round((Date.now() - startedAt) / 1000),
version: process.env.APP_RELEASE ?? "dev",
});
} catch (err) {
res.status(503).json({ status: "down", reason: String(err.message) });
}
});
app.listen(Number(process.env.PORT ?? 3000));
Keep the timeout tight — 750ms here — because a health check that hangs is worse than one that fails. And don’t call an external API from inside it. A /healthz that reports your app down because a metrics endpoint had a slow minute has invented an outage.
Beat at the end of the job, not the start
The heartbeat goes after the work, carries the work, and is a single call:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/metrics/report" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "heartbeat.nightly_invoice",
"value": 812,
"type": "timing",
"tags": { "job": "nightly-invoice", "env": "prod", "outcome": "ok" }
}'
{
"ok": true,
"data": { "accepted": true, "metric_id": "metric_WmDQNYSKDbepYSEWDRCoHz88" },
"metadata": { "request_id": "req_16157dcca9bb41acaee2e000", "latency_ms": 85 }
}
value is the invoice count, so the same series answers “did it run” and “did it do enough”. type has to be one of counter, gauge, timing or distribution.
The field that makes a dead-man’s switch possible
GET /v1/metrics/query has no from or to parameter — you can’t ask for the last hour. What you can use is the ts on the returned point:
curl -sS "https://api.infrai.cc/v1/metrics/query?name=heartbeat.nightly_invoice&agg=count&tag.job=nightly-invoice" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"name": "heartbeat.nightly_invoice",
"agg": "count",
"points": [{ "ts": "2026-07-26T01:24:08.344462Z", "value": 1.0 }]
}
}
In our testing ts carries the timestamp of the most recently ingested matching row, so as long as the job never sets the optional timestamp field itself, ts is the moment of the last beat. now - ts > interval * 1.5 is your alarm. The catch is real and worth stating: write a point with an explicit backdated timestamp and ts reports that backdated value instead — we saw a beat written second with a July 1st timestamp pull ts backwards. Don’t backfill into a series you also use as a switch.
A job that has never beaten at all comes back as "points": [], which your watcher must treat as stale rather than as a missing metric.
The watcher
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const SCHEDULE = [
{ job: "nightly-invoice", name: "heartbeat.nightly_invoice", every_s: 86_400, min_value: 1 },
{ job: "hourly-sync", name: "heartbeat.hourly_sync", every_s: 3_600, min_value: 0 },
];
async function lastBeat({ name, job }) {
const qs = new URLSearchParams({ name, agg: "count", "tag.job": job });
const res = await fetch(`${API}/v1/metrics/query?${qs}`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!res.ok) throw new Error(`query ${name} -> HTTP ${res.status}`);
const point = (await res.json()).data.points[0];
return point ? { at: Date.parse(point.ts), beats: point.value } : null;
}
async function sweep() {
const now = Date.now();
const alarms = [];
for (const entry of SCHEDULE) {
let beat;
try {
beat = await lastBeat(entry);
} catch (err) {
alarms.push({ job: entry.job, reason: `watcher failed: ${err.message}` });
continue;
}
if (!beat) {
alarms.push({ job: entry.job, reason: "never reported" });
continue;
}
const ageS = Math.round((now - beat.at) / 1000);
if (ageS > entry.every_s * 1.5) {
alarms.push({ job: entry.job, reason: `stale by ${ageS - entry.every_s}s`, age_s: ageS });
}
}
return { checked_at: new Date(now).toISOString(), alarms };
}
console.log(JSON.stringify(await sweep(), null, 2));
Run that on a schedule somewhere that isn’t the box running the jobs — that’s the whole point of a dead-man’s switch. Infrai’s cron namespace is on the same key if you’d rather not stand up a second host for it, and the alert itself can go out over the same account’s email or SMS routes without a third vendor appearing on the invoice.
Cost and the tempting mistake
Each beat is one billable point at $0.001. An hourly job beats 24 times a day — roughly $0.72 a month. Beat every five minutes instead and the same job costs $8.64, which is a useful reminder that resolution isn’t free. The reads the watcher does are free and don’t consume the new-account trial. Verified 2026-07-26 — check yours with:
curl -sS "https://api.infrai.cc/v1/account/balance" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Prices here drift downward and campaigns run, so the live figure may be lower. The tempting mistake is beating every second “for resolution”: POST /v1/metrics/batch is billed per point rather than per request, so a batch of 60 one-second beats costs the same as sixty separate calls. Beat at the granularity your alarm threshold actually uses.
When to buy this instead
Healthchecks.io exists for exactly this and its free tier covers twenty checks with a UI, escalation and a hosted ping URL — if a cron dead-man’s switch is your only requirement, that’s less code than the script above. Prometheus with the Pushgateway does it too, though the Pushgateway’s lack of TTL semantics makes staleness detection its own project. Datadog and New Relic both ship proper monitor types with schedules and on-call routing, and if you already pay for either, use them.
What you get here instead is one credential. The heartbeat, the counters behind the admin dashboard, the queue the job pulls from and the email the alarm goes out on all sit on one account and one bill — which for a two-person team is usually worth more than a nicer checks UI.