Vercel cron jobs are one vercel.json stanza and zero infrastructure, which is why the reports, syncs, cleanups, and digest emails of half the Next.js apps in production quietly run on them. The operational fine print is less advertised: failed invocations are not retried, Hobby-plan schedules run once a day at a time Vercel picks, schedules are UTC-only, and a failure's only witness is a log line nobody is watching. A cron job that starts failing on Friday will still be failing at Monday standup — with three missed runs and no notification.

This is the same silent-death genre as GitHub Actions schedules and systemd timers, and it has the same fix: a heartbeat monitor that expects a ping from every successful run and alerts on silence. One line of code per job.

How Vercel crons fail — a field guide

  • The function throws. A dependency call fails, a schema changed, an env var is missing on production. The invocation 500s, the run's work didn't happen, and there is no retry — the next attempt is the next scheduled run.
  • The function hits its duration ceiling. Cron handlers are ordinary serverless functions with a max duration. A job that grew with your data — the nightly export that now takes four minutes — dies mid-run at the limit. Partial work, no completion, log line only.
  • The schedule doesn't fire when you think. Hobby-plan crons run at most once per day with Vercel choosing the moment (as of 2026); every plan evaluates schedules in UTC. Neither is a bug, but both defeat any monitoring that expects to-the-minute punctuality.
  • The cron config silently changes. Crons are declared in vercel.json and applied by the production deployment. A refactor that drops the stanza, a branch that never merged it, a monorepo config move — the job is simply gone, and nothing announces the absence.
  • The project state changes. Paused projects, transferred ownership, a rolled-back deployment that predates the cron — the schedule stops with the state.
  • Someone else can call the route. Not a missed run, but the same neighborhood: cron handlers are public routes on your production URL. Without a CRON_SECRET check, anyone who finds the path can trigger the job.

The pattern across all of these: the platform records what happened (or doesn't), and no human finds out. Detection has to be based on silence — the absence of a success signal — because half the failure modes never execute your code.

The heartbeat pattern: one fetch at the end

Create a heartbeat monitor in CronAlert with the job's expected interval plus grace, and ping it as the final act of a successful run:

// app/api/cron/daily-report/route.ts
export async function GET(req: Request) {
  // Vercel sends this header automatically when CRON_SECRET is set
  const auth = req.headers.get('authorization');
  if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 });
  }

  await generateDailyReport();

  // Success signal — only reached if the work completed
  await fetch('https://cronalert.com/api/heartbeat/YOUR_TOKEN');

  return Response.json({ ok: true });
}

The placement rules are the same as for any workflow heartbeat: last, and only on the success path. If generateDailyReport() throws, the fetch never runs, the ping never arrives, and CronAlert alerts you after the grace period. Every failure mode in the field guide above — throw, timeout, unfired schedule, deleted config, paused project — collapses into the same observable: the pings stop.

And note what the heartbeat catches that a try/catch can't: the runs that never started. An error handler inside the function can report a throw, but it cannot report that the schedule didn't fire or that the cron stanza was deleted in last week's deploy. Loud in-function error reporting and silence-based heartbeats are complements — use both.

Setting the expected interval (don't fight the platform)

  • Pro-plan precise schedules (say, every 15 minutes): expected interval 15 minutes, grace of a few minutes to absorb duration variance and invocation jitter.
  • Hobby-plan daily jobs: Vercel picks the time within a window, so don't anchor to the minute. Expect one ping per ~24 hours with an hour or two of grace. If the ping-to-ping gap matters less than "did it run today," this is exactly the contract a heartbeat expresses.
  • UTC conversions: write the schedule in UTC (Vercel gives you no choice), then sanity-check what that means locally — the classic miss is a "nightly" job that runs mid-business-day in your timezone, or a "1st of the month" job that fires on the evening of the 31st locally.
  • Long jobs: if the run takes minutes, the grace period must cover schedule jitter plus runtime. A useful upgrade is a start/finish pair — ping once at job start and once at completion — so you can distinguish "never started" from "started and died," the same pattern as in our batch job guide.

Catching the duration ceiling before it catches you

The nastiest Vercel cron failure is the slow-growth one: the job that fit comfortably in its max duration at launch and crossed the line eighteen months later as data accumulated. Two defenses:

  • Watch the trend, not the threshold. If you use the start/finish pair, the gap between pings is your job's runtime. A runtime that has doubled in six months is an alert-worthy fact long before it's an outage.
  • Chunk the work. Vercel's ceiling is per-invocation. Jobs that process "everything since last run" should page through work with a cursor so a single run stays bounded — and so a failed run resumes rather than reprocesses. The heartbeat then means "a bounded chunk completed," which is a promise the platform can actually keep.

Set it up in ten minutes

  • Create a CronAlert account — heartbeat monitors are a Pro feature at $5/mo, which covers 100 monitors; your app's HTTP monitors fit on the free plan.
  • Create one heartbeat monitor per cron job, with interval + grace per the section above.
  • Add the success-path fetch as the last line of each handler.
  • Set CRON_SECRET and reject unauthenticated calls — the heartbeat is outbound, so locking down the route costs your monitoring nothing.
  • Test the failure path: comment out the ping (or throw early), deploy to preview, trigger the handler, and confirm the alert arrives after the grace period. An untested alert pipeline is a hope, not a system — see our alert fire-drill guide.

Frequently asked questions

Do Vercel cron jobs retry on failure?

No (as of 2026). A failed run is gone until the next scheduled one, recorded only in logs. Heartbeats turn that silence into an alert.

Why didn't my cron run at the exact time?

Hobby-plan crons run once daily at a time Vercel chooses; all schedules are UTC. Set heartbeat intervals to the period plus generous grace instead of expecting punctuality.

How do I get alerted when a cron fails?

A heartbeat ping as the last line of the handler, after the work. Missed ping → alert on email, Slack, PagerDuty, or webhook.

How do I secure the cron endpoint?

Set CRON_SECRET; Vercel sends it as a bearer header on cron invocations, and your handler rejects everything else. Monitoring is unaffected — heartbeats are outbound.

HTTP check or heartbeat for cron routes?

Heartbeat. An HTTP monitor would trigger the job's side effects (and be rejected by your auth check). Heartbeats detect the runs that never happened — the thing inbound checks can't see.

Your crons are production — treat the silence as a signal

Vercel gives scheduled jobs a great developer experience and no pager. The gap between those two is one fetch call per job. Set up CronAlert, add the heartbeat line to the cron whose failure would embarrass you most, and test-fail it before you trust it.

Related reading: cron job heartbeat monitoring, monitoring GitHub Actions schedules, monitoring serverless functions, and Next.js on Vercel monitoring.