You found out the hard way. A Vercel cron job that syncs invoices, or rebuilds a cache, or sends the digest email quietly stopped doing its thing, and nobody noticed for days because nothing anywhere turned red. If you came here after searching "vercel cron not running", this article is the answer you were hoping the Vercel dashboard would give you: a way to get paged when a cron run fails, times out, or never happens at all.
What Vercel's docs actually say about cron failures
This isn't undocumented behavior. Vercel's cron jobs documentation is direct about it: Vercel will not retry an invocation if a cron job fails, and delivery is best-effort, so a run can be skipped entirely. And here's the part that bites: a missed run produces no runtime log. Your function was never invoked, so there is nothing to log. The dashboard shows you the runs that happened, not the ones that didn't.
On the Hobby plan the constraints stack up further: cron jobs can run at most once per day, the invocation lands somewhere within a ±59-minute window of the scheduled time rather than on the dot, and schedules are interpreted in UTC only. A cron you scheduled for 08:00 might fire at 08:47, and that's working as designed. These limits change from time to time, so check Vercel's live docs for the current numbers before you tune anything around them.
Put together: no retries, no failure notifications, best-effort delivery, and an hour of timing slop. Vercel gives you a scheduler, not a monitor. That's a reasonable division of labor, but it means the monitoring is your job.
Three silent failure modes your logs can't catch
"I'll just check the logs" fails here for a structural reason. There are three distinct ways a Vercel cron run goes wrong, and logs only ever see one of them clearly:
- Never invoked. Best-effort delivery skipped the run, or a bad
vercel.jsonpath meant the schedule never attached to a function. No invocation means no log line exists at all. You cannot grep for an absence. - Invoked but threw. The handler ran and crashed: an expired API key, a schema change, an unhandled promise rejection. This one does log an error, but Vercel doesn't alert you about it, and on Hobby the short log retention means the evidence evaporates before you go looking. A failure you discover by scrolling logs three days later might as well be silent.
- Invoked but timed out. The function hit its duration limit mid-work. The database sync got through 40% of the rows and stopped. Depending on where it died, the log can even look mostly normal.
Any monitoring approach that starts from "watch the logs" can only ever catch mode 2, and only while the logs still exist. Modes 1 and 3 need something that notices what didn't happen.
The fix: a dead man's switch on every cron
The pattern that covers all three modes is a heartbeat monitor, also known as a dead man's switch. Instead of you probing the job, the job reports in: at the end of every successful run, it pings a unique URL. The monitor knows the expected period (how often pings should arrive), allows a grace period for slow runs and scheduler slop, and opens an incident after a configurable failure threshold of missed pings. Silence becomes the alarm.
This inverts the problem. A skipped run, a crash, and a timeout all produce the exact same observable signal (no ping arrived), so one cheap mechanism catches all of them. We wrote up the full model, including how periods, grace windows, and thresholds interact, in designing a heartbeat monitoring system. In upti.my you create a heartbeat monitor, set the period to your cron schedule, and get a URL like https://heartbeats.upti.my/v1/monitors/<token>. The free plan includes 5 heartbeats, which covers most projects' entire vercel.json.
Implementation: a cron route handler that reports in
Here's the full pattern for a Next.js app-router cron endpoint. Two things matter: verify Vercel's CRON_SECRET so random internet traffic can't trigger the job, and ping the heartbeat only after the work succeeds, as the last thing the handler does.
import { NextResponse } from "next/server";
import { syncInvoices } from "@/lib/invoices";
export async function GET(request: Request) {
// 1. Verify this is really Vercel's cron scheduler calling.
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 2. Do the actual work. Let errors throw: a failed run
// must NOT reach the heartbeat ping below.
const result = await syncInvoices();
// 3. Report success. Only reached if syncInvoices() didn't throw.
await fetch(
"https://heartbeats.upti.my/v1/monitors/<your-token>"
);
return NextResponse.json({ ok: true, synced: result.count });
}The ping endpoint accepts GET, POST, and HEAD, so a bare fetch is enough; no body or auth headers required. If the sync throws on step 2, the function errors out, the ping never fires, and after the grace period expires upti.my opens an incident. If Vercel never invokes the function at all, exact same outcome. That symmetry is the whole point. Full endpoint details are in the heartbeat docs, and there's a broader cron-focused walkthrough on the cron job monitoring page.
Catch runs that finish but produce nothing
There's a fourth failure mode heartbeats alone don't catch: the run that completes cleanly and does zero work. The upstream API returned an empty array, the query matched no rows, and your sync "succeeded" while syncing nothing. The handler reaches the ping, the monitor stays green, and the data quietly goes stale. We covered why completion is not the same as correctness in why "cron job started" does not mean "finished".
The cheap defense is a guard in your own code: check the output before pinging.
const result = await syncInvoices();
if (result.count <= 0) {
// Zero rows is a failure for this job. Don't ping;
// let the heartbeat go stale and raise the alert.
throw new Error("Invoice sync completed with 0 rows");
}
await fetch("https://heartbeats.upti.my/v1/monitors/<your-token>");For multi-step jobs (fetch, transform, load), you can go further with a Heartbeat Job Chain: each step pings with a named stepKey and a value, and the monitor applies validation rules per step, such as greater_than on the row count. The chain then tells you which step went missing or reported a bad value, not just that the whole job did.
// Step 1: extraction finished, report how many rows we pulled
await fetch("https://heartbeats.upti.my/v1/monitors/<token>", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
stepKey: "extract",
value: rows.length, // rule: greater_than 0
}),
});
// Step 2: load finished, report rows written
await fetch("https://heartbeats.upti.my/v1/monitors/<token>", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
stepKey: "load",
value: written, // rule: greater_than 0
}),
});Tuning period and grace for Vercel's timing slop
This is where most first attempts generate false alarms. Remember the Hobby-plan behavior: a cron scheduled for 08:00 UTC actually fires anywhere from 08:00 to 08:59. If you set a 24-hour period with a five-minute grace window, a run that fires at 08:10 one day and 08:55 the next will page you for a job that's perfectly healthy.
Concrete numbers for a daily Hobby cron:
- Period: 24 hours. Match the schedule, not the observed arrival times.
- Grace: at least 75 minutes. That covers the full 59-minute invocation window plus a worst-case run duration with margin. If the job itself can take 10 minutes, budget 59 + 10 + slack.
- Failure threshold: 1 for a daily job. When a run only happens once a day, waiting for a second miss means two days of stale data before anyone hears about it.
On Pro, where crons fire close to on time and can run more often, you can tighten grace to a few minutes past worst-case runtime, and for high-frequency schedules a failure threshold of 2 absorbs the occasional best-effort skip without waking anyone. More sizing guidance lives in how to monitor cron jobs properly.
Catch coverage drift in CI
The failure mode after you adopt this pattern is drift: someone adds a fourth cron to vercel.json and forgets the heartbeat. Heartbeat monitors are created and managed in the upti.my app (they are a separate feature from the pull-based healthchecks that the uptimyctl CLI manages), so the drift guard belongs in your repo instead: keep an explicit mapping from each cron path to the env var holding its ping URL, and make CI enforce it.
{
"/api/cron/sync-invoices": "HEARTBEAT_URL_SYNC_INVOICES",
"/api/cron/send-digests": "HEARTBEAT_URL_SEND_DIGESTS",
"/api/cron/cleanup-sessions": "HEARTBEAT_URL_CLEANUP_SESSIONS"
}- name: Verify every Vercel cron has a heartbeat mapping
run: |
for path in $(jq -r '.crons[].path' vercel.json); do
if ! jq -e --arg p "$path" 'has($p)' heartbeats.json > /dev/null; then
echo "No heartbeat mapping for cron: $path" && exit 1
fi
doneYour route handlers read the ping URL through this same mapping, so the file can't silently rot: a cron with no entry fails CI, and an entry with no env var fails loudly at runtime. It's not as satisfying as querying the monitoring service directly, but it's honest about what's automatable today, and it catches the forgot-the-heartbeat mistake at review time instead of three weeks into a silent failure.
Testing a heartbeat means pinging it
curl https://heartbeats.upti.my/v1/monitors/<token>, watch the ping register, then let the schedule lapse once and confirm the missed-ping alert fires.Common mistakes
Pinging at the start of the handler
A ping on the first line of the function tells you Vercel invoked the route, and nothing else. The job can throw one millisecond later and your monitor stays green. Always ping last, after the work and after the output check. If you want start visibility too, use a job chain step for it rather than moving the success ping.
No grace period
Setting grace to zero (or a token five minutes) on a Hobby cron guarantees false alarms, because the ±59-minute window is normal operation, not a fault. A monitor that cries wolf twice a week gets muted, and a muted monitor is worse than none.
Forgetting CRON_SECRET
Without the auth check, your cron route is a public URL that anyone can hit. Beyond the obvious abuse, an outside request that happens to succeed will ping your heartbeat and mask a genuinely broken schedule. Set CRON_SECRET in your Vercel environment variables and reject requests that don't carry it.
Alerting straight to your phone without dedupe
Wiring the incident directly to SMS or push means a flapping job pings your phone repeatedly at 3 a.m. Route through an alert workflow with deduplication instead; the pre-built templates on the free plan handle this. (Fully custom drag-and-drop workflows are a Growth feature at $39/mo with a 14-day trial; Free and Starter get the pre-built templates, which are enough here.)
📌Key Takeaways
- 1Vercel does not retry failed cron invocations, delivery is best-effort, and a missed run leaves no log to find
- 2Three silent failure modes: never invoked, invoked but threw, invoked but timed out. Logs only see the second, briefly
- 3A heartbeat (dead man's switch) turns all three into the same signal: a missing ping, which becomes an alert
- 4Ping only on success, as the last step, and validate output (rows > 0) before pinging so empty runs don't stay green
- 5Daily Hobby crons fire within a 59-minute window, so use a 24h period with at least 75 minutes of grace
- 6Enforce coverage in CI with a cron-to-heartbeat mapping file: the build fails when a vercel.json cron has no entry
A Vercel cron that fails is quiet by design; your monitoring shouldn't be. One heartbeat URL per cron closes the gap, and the free plan's 5 heartbeats cover a typical vercel.json with room to spare. Create a free heartbeat monitor and the next silent failure won't be.