Cron heartbeat and missed-run detection for Next.js jobs, without a ping service
A dead-man's switch built from two metrics routes: bucket the heartbeat at write time, then alert when the count for the bucket you expected comes back empty.
A job that fails loudly is easy. A job that never starts is the one that quietly costs you a month of invoices, and no exception handler will ever fire for it. Detecting that means inverting the question: instead of watching for an error, watch for the absence of a heartbeat. With Infrai you write the heartbeat through POST /v1/metrics/report and check for it with the free GET /v1/metrics/query — the trick is putting a time bucket in the tags so “did tonight’s run happen” is a question the read side can actually answer.
Healthchecks.io and Cronitor sell exactly this as a hosted product, and they’re good at it. What follows is the version you build yourself when you’d rather not add a fourth vendor for a feature that’s two HTTP calls and eleven lines of watcher.
Absence needs a shape
GET /v1/metrics/query returns one aggregate over the whole retained series for a metric name, filtered by tags. There’s no from or to parameter, so a raw count of cron.heartbeat grows forever and tells you nothing about tonight.
Fix that at write time. Stamp each heartbeat with the period it belongs to:
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": "cron.heartbeat",
"value": 1,
"type": "counter",
"tags": {"job": "nightly-invoices", "bucket": "2026-07-26T00", "outcome": "ok"}
}'
{
"ok": true,
"data": { "accepted": true, "metric_id": "metric_BTCkNRxQICuQN29ZNd9mLIdF" },
"metadata": { "request_id": "req_b1917e74d0004e668edef805", "latency_ms": 24 }
}
Now the check is exact. Ask for the bucket that should exist:
curl -sS "https://api.infrai.cc/v1/metrics/query?name=cron.heartbeat&agg=count&tag.job=nightly-invoices&tag.bucket=2026-07-26T00" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
A run that happened comes back with a count. A run that didn’t comes back with an empty points array, and that emptiness is your alert:
{
"ok": true,
"data": { "name": "cron.heartbeat", "agg": "count", "points": [] }
}
Pick the bucket granularity to match the schedule — 2026-07-26 for a daily job, 2026-07-26T00 hourly, 2026-07-26T00:15 for a quarter-hour job. Anything finer starts creating tag values faster than you’ll ever query them.
The Next.js side
App Router route handlers work fine as cron targets on Vercel or anywhere else. The pattern that matters: report the heartbeat in a finally, so a job that crashed halfway still records that it started, tagged with the outcome.
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
const API = "https://api.infrai.cc";
function bucket(d: Date): string {
return d.toISOString().slice(0, 13); // "2026-07-26T00"
}
async function heartbeat(job: string, outcome: string, ms: number): Promise<void> {
const key = process.env.INFRAI_API_KEY;
if (!key) {
console.error("INFRAI_API_KEY is not set; skipping heartbeat");
return;
}
const res = await fetch(`${API}/v1/metrics/report`, {
method: "POST",
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
body: JSON.stringify({
name: "cron.heartbeat",
value: ms,
type: "gauge",
tags: { job, outcome, bucket: bucket(new Date()) },
}),
});
if (!res.ok) console.error(`heartbeat rejected: HTTP ${res.status}`);
}
export async function GET(): Promise<NextResponse> {
const started = Date.now();
let outcome = "ok";
try {
await generateInvoices();
return NextResponse.json({ ok: true });
} catch (err) {
outcome = "failed";
console.error("nightly-invoices threw", err);
return NextResponse.json({ ok: false }, { status: 500 });
} finally {
await heartbeat("nightly-invoices", outcome, Date.now() - started);
}
}
async function generateInvoices(): Promise<void> {
await new Promise((r) => setTimeout(r, 50));
}
The schedule itself stays where your platform expects it:
{
"crons": [
{ "path": "/api/cron/invoices", "schedule": "0 0 * * *" }
]
}
The watcher
Something has to notice the silence, and it can’t be the job that went silent. Run this from a different machine — another host, a GitHub Actions schedule, your laptop’s crontab if the stakes are low:
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const HOOK = process.env.ALERT_WEBHOOK_URL;
if (!KEY || !HOOK) throw new Error("set INFRAI_API_KEY and ALERT_WEBHOOK_URL");
// The hour that finished at least five minutes ago, so a slow job isn't
// reported missing while it's still running.
const now = new Date(Date.now() - 65 * 60 * 1000);
const expected = now.toISOString().slice(0, 13);
const url = new URL(`${API}/v1/metrics/query`);
url.searchParams.set("name", "cron.heartbeat");
url.searchParams.set("agg", "count");
url.searchParams.set("tag.job", "nightly-invoices");
url.searchParams.set("tag.bucket", expected);
const res = await fetch(url, { headers: { authorization: `Bearer ${KEY}` } });
if (!res.ok) {
console.error(`query failed: HTTP ${res.status}`);
process.exit(1);
}
const { data } = await res.json();
const runs = data.points[0]?.value ?? 0;
if (runs === 0) {
await fetch(HOOK, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: `nightly-invoices did not run for bucket ${expected}` }),
});
console.error(`MISSED ${expected}`);
} else {
console.log(`ok: ${runs} run(s) in ${expected}`);
}
Sixty-five minutes of lag is the grace period. Tune it to the job’s usual duration plus a margin — a five-minute job with a one-minute grace window will page you for a slow night, and that’s how people learn to ignore alerts.
What it costs to run
Each heartbeat is one billable write at $0.001 per call, verified 2026-07-26. A job running every hour costs about $0.72 a month; the watcher’s queries are free and rate-limited, so polling costs nothing. New accounts get $2 in credit, which covers roughly 1,999 writes before you top up.
Check today’s rate rather than trusting the paragraph above — figures on this platform have moved down over time:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id == "metrics.report") | .billing'
Choosing between the three ways to do this
| Approach | Who notices the silence | Ongoing cost | Setup |
|---|---|---|---|
| Healthchecks.io / Cronitor | their scheduler | per check, monthly | paste a ping URL into the job |
| Prometheus Pushgateway + Alertmanager, or Grafana Cloud alert rules | your Alertmanager | your servers, or a Grafana plan | Pushgateway, scrape config, alert rules |
| Metrics API + your own watcher | a cron you run elsewhere | per heartbeat write, reads free | two calls and a script |
The honest limitation of the third row is in its own description: you have to run the watcher, and a watcher on the same box as the job is worth nothing. That’s precisely the part a hosted dead-man’s switch sells you, and if you don’t have a second place to run a five-line script, stick with Healthchecks.io. Prometheus with a Pushgateway is the better fit if short-lived jobs are already scraped alongside your services — though the Pushgateway’s own docs are blunt about it being a workaround rather than a design.
What the API version buys you is that the heartbeat isn’t a special-purpose signal. It’s a normal metric on the same key that also carries your request counters, your error capture and your storage — so “which jobs were slowest last week” and “did the invoice job run” are the same query surface and one bill, not two dashboards and two invoices.