CronAlert runs on Cloudflare Cron Triggers — a crons = ["* * * * *"] line in wrangler.toml fires our check engine every minute, and everything you're monitoring with us depends on it firing. So this guide is dogfood: the failure modes below are the ones we actually engineered around, and the heartbeat pattern at the end is how a scheduler monitors itself when it can't take its own word for anything.

Cron Triggers deserve the same skepticism as GitHub Actions schedules and Vercel crons: the platform records what happened, retries nothing, and pages nobody. But Workers add two failure modes of their own — dropped async work and overlapping runs — that make "the dashboard says it ran" even less meaningful than usual.

The Workers-specific failure modes

1. The run "succeeds" and the work silently vanishes

Workers terminate outstanding async operations when your handler returns, unless they were awaited or registered with ctx.waitUntil(). The classic bug looks like this:

export default {
  async scheduled(event, env, ctx) {
    // BUG: not awaited -- killed when the handler returns.
    // The dashboard records a successful invocation anyway.
    processQueue(env);

    // Correct: await the critical path
    await processQueue(env);

    // waitUntil is for genuinely fire-and-forget extras
    // (metrics, cache warming) -- never for the job itself
    ctx.waitUntil(recordMetrics(env));
  },
};

The un-awaited version is uniquely nasty because every observable signal says success: the invocation completes fast, no error is logged, cron history is green. We hit a variant of this ourselves (audit writes dropped because a promise wasn't awaited before the response returned) — it's the number-one thing to check when a Worker cron "runs" but its side effects don't happen.

2. Runs overlap, and each run might be somewhere else

If a pass takes longer than the schedule interval, the next firing starts anyway — possibly in a different data center. Any check-then-write logic in the job is now a race: two overlapping passes both see "no record exists," both insert, and you've double-processed. The fix is to make writes atomic instead of sequenced — unique indexes plus ON CONFLICT upserts, never SELECT-then-INSERT. In CronAlert's case the symptom would be duplicate incidents (and duplicate alert emails); the unique-index-plus-upsert pattern is what guarantees one incident per outage no matter how passes interleave.

3. The schedule is deploy-state, and deploys change

Cron Triggers live in wrangler.toml and take effect on deploy. A refactor that drops the crons line, a rollback to a version that predates it, a monorepo config shuffle, a rename that deploys a fresh worker while the scheduled one rots — the trigger is gone and nothing announces the absence. This is config drift, the same genre as a deleted vercel.json stanza, and only silence-based detection catches it, because there's no code left to throw an error.

4. Limits, quietly

Scheduled handlers run under CPU-time ceilings, and a job that grew with your data can start dying mid-run months after it was written. Failures land in logs you're probably not streaming (wrangler tail is ephemeral; persistent logging needs explicit setup) — and there's no retry, so a job that hits its ceiling every run just stops producing results indefinitely (as of 2026).

The heartbeat pattern

One external monitor per cron trigger, expecting a ping on the schedule's cadence plus grace:

export default {
  async scheduled(event, env, ctx) {
    await runScheduledWork(env);

    // Success signal -- awaited, after the real work.
    // Skipped on throw, so the missed beat raises the alert.
    await fetch('https://cronalert.com/api/heartbeat/YOUR_TOKEN');
  },
};

Three placement rules, all load-bearing:

  • After the work — the ping asserts "this run completed its job," not "this run started."
  • Awaited — a ping inside a dangling promise or a casually-used waitUntil can be dropped exactly like the work in failure mode #1, giving you false alarms (or worse, a ping that survives while the work didn't).
  • Not wrapped in a catch that swallows — if you try/catch the job for logging, re-throw or return early so the ping stays unreachable on failure.

Set the expected interval to the schedule and the grace to cover runtime variance: an every-minute trigger might expect a ping every minute with a minute or two of grace; an hourly job, an hour plus a few minutes. Every failure mode above — dropped work, deleted schedule, rollback, CPU ceiling, all-night throwing — collapses into the same observable: the pings stop. For long jobs, the start/finish pair from our batch-job guide distinguishes "never started" from "died mid-run."

One honest note on architecture: a heartbeat monitor should not share fate with the thing it watches. CronAlert's own cron engine can't be its only observer — we point independent checks at it, and if your whole stack lives on one Cloudflare account, it's worth having at least the heartbeat monitor live outside it. That's the entire argument for external monitoring, applied to the scheduler itself.

Testing it (locally and for real)

  • Locally: cron schedules don't self-fire in wrangler dev. Use wrangler's scheduled-testing mode to hit the local /__scheduled endpoint with your cron expression (the exact flag has shifted across wrangler versions — check current docs).
  • The failure path: make the job throw on purpose in a preview, confirm no ping fires, and confirm the alert arrives after grace. A heartbeat you've never seen fail is a hope, not a system.
  • The drift path: after any deploy that touches wrangler.toml or renames workers, glance at the cron events in the dashboard once — then let the heartbeat be the thing that notices next time, because you won't remember to look.

Set it up in ten minutes

  • Create a CronAlert account — heartbeat monitors are on Pro at $5/mo (100 monitors); HTTP monitors for the Worker's public routes fit on the free plan.
  • One heartbeat monitor per cron trigger, interval = schedule + grace.
  • Add the awaited ping after the awaited work; audit the handler for un-awaited promises while you're in there.
  • Make the job's writes idempotent if runs can ever overlap.
  • Test-fail it before you trust it.

Frequently asked questions

Does Cloudflare alert on failed Cron Triggers?

No — history and logs exist, pages don't. And nothing at all fires for runs that never happened. Heartbeats close both gaps.

Why does a successful run do nothing?

Un-awaited promises are killed when the handler returns, while the invocation is recorded as successful. Await the critical path; reserve waitUntil for extras.

Can runs overlap?

Yes, when a pass outlasts the interval. Make writes atomic (unique index + upsert) or you'll double-process under overlap.

Do failed runs retry?

No automatic retries (as of 2026). Design runs to catch up idempotently, and detect repeated failure externally.

How do I test locally?

Wrangler's scheduled-testing mode plus the local /__scheduled endpoint; then test the failure path against a real monitor.

Trust the beat, not the dashboard

We run an every-minute Cron Trigger that other people's alerting depends on, and the operating lesson is simple: the dashboard tells you what Cloudflare invoked, and only a heartbeat tells you what your job actually finished. One awaited fetch per trigger buys that certainty. Set up CronAlert, add the ping to your most load-bearing cron, and test-fail it today.

Related reading: monitoring Cloudflare Workers and Pages, cron job heartbeat monitoring, monitoring Vercel cron jobs, and monitoring serverless functions.