Heroku Scheduler has a property that surprises people who read the documentation only after an incident: it is officially a best-effort service. Heroku's own docs say scheduled runs can occasionally be skipped or delayed, and recommend a custom clock process for jobs that must run. There are no retries. There are no failure notifications. A job that silently doesn't fire looks exactly like a job that fired fine — unless something outside Heroku is expecting proof.
That makes Scheduler the purest case yet of the rule we keep hitting across cron, GitHub Actions schedules, Vercel crons, and Celery Beat: the failure signal is silence, and the platform will not break that silence for you. Here Heroku says so in writing.
How Scheduler jobs fail — a field guide
- The run is skipped. Not a crash, not an error — the best-effort scheduler just didn't fire this one. Nothing appears anywhere, because nothing happened. This is the failure mode Heroku documents and the one no amount of in-app error handling can see.
- The one-off dyno fails to boot. Scheduler runs each job in a fresh one-off dyno. A bad release, a missing dependency, or a config-var change since the last deploy means the dyno crashes before your code runs a single line. Your error tracker never loads, so it never reports.
- The job runs and exits non-zero. No retry, no alert. The dyno logs the failure and evaporates — and one-off dyno logs are ephemeral; without a log drain, even forensics is gone in a scroll of
heroku logs. - The job is killed mid-run. Memory over the dyno limit (the R14/R15 family) or a platform interruption terminates the dyno partway. Half a job ran. Whether that's harmless or a corrupted export depends entirely on whether the job is idempotent — same story as any batch job.
- The schedule was edited or removed. Scheduler's job list is dashboard state, not code. It isn't in your repo, doesn't survive review, and one teammate's cleanup click deletes a job with no audit trail. (This is the Heroku flavor of the config-drift problem — except there's not even a config file to diff.)
- UTC drift. Daily jobs run at fixed UTC times. Twice a year, "the 8 AM report" quietly becomes the 7 AM or 9 AM report in local time.
Six failure modes, and — as always with schedulers — one shared observable: the job's completion stops happening on schedule.
The heartbeat pattern: chain the ping onto the command
Scheduler jobs are shell commands, which makes the fix a one-liner. Create a heartbeat monitor in CronAlert, set its expected interval to the job's cadence, and chain the ping with && so it fires only when the job succeeds:
python manage.py send_digest && curl -fsS https://cronalert.com/api/heartbeat/YOUR_TOKEN That's the whole integration. The && is doing the important work: if the job exits non-zero, is killed mid-run, never boots, or is never scheduled at all, the ping doesn't happen, and CronAlert alerts you after the grace window. Every failure mode in the field guide collapses into one alert.
For multi-step jobs, wrap them in a script with set -euo pipefail so a failure anywhere in the pipeline blocks the ping — the same verified-success pattern we recommend for backups (where "the command ran" and "the backup is good" are very different claims).
Match the monitor's expected interval to Scheduler's fixed cadences: 10 minutes, hourly, or daily. Don't set it tighter than the job's schedule hoping for earlier warning — you'll only manufacture false alarms. Detection speed comes from the job's cadence, not the monitor's impatience.
A canary for the machinery itself
Per-job heartbeats tell you a daily job missed — tomorrow. To find out today that Scheduler (or your app's ability to boot a one-off dyno) is broken, add a canary: a 10-minute Scheduler job whose only work is the ping.
curl -fsS https://cronalert.com/api/heartbeat/CANARY_TOKEN Point it at a heartbeat monitor expecting a ping every 10 minutes. A missing canary ping means the scheduling machinery — Scheduler itself, your release, or dyno boot — is broken for every job, and you hear about it within minutes instead of after the nightly billing run doesn't happen. This is the same beat-liveness canary logic we use for Celery, and it's the first monitor to set up.
One honest caveat: a 10-minute one-off dyno consumes dyno hours. The canary command runs for well under a second, so the cost is minutes of dyno time per day — but it's not zero, and on heavily quota-constrained accounts you can run the canary hourly instead and accept slower detection.
If you use a clock process instead
Heroku's recommended upgrade path for reliability is a custom clock process — a dedicated dyno running APScheduler, the clockwork gem, or similar, enqueueing work to your background workers. That genuinely fixes best-effort scheduling. It also recreates a familiar architecture: one process that schedules everything and reports nothing — exactly the shape of Celery Beat, with the same monitoring answer:
- A canary job on a tight schedule pinging a heartbeat — proves the clock dyno, the queue, and a worker end to end. Catches the clock dyno crashing, but also the subtler failure where the clock enqueues happily to a queue nothing is draining.
- Per-task heartbeats on the jobs that matter, pinged at the end of the worker's execution, success path only — never at enqueue time. "Scheduled" is not "done."
- Watch for a scaled-to-zero clock dyno after a deploy or a
heroku ps:scaletypo — from the platform's perspective that's a configuration, not a failure, so nothing alerts. The canary catches it.
Set it up in ten minutes
- Create a CronAlert account — heartbeat monitors are on the Pro plan ($5/mo, 100 monitors, 1-minute checks).
- Add the canary first: a 10-minute Scheduler job that's just the curl, paired with a 10-minute heartbeat monitor.
- Chain
&& curl -fsS <heartbeat URL>onto each Scheduler job that matters, with a monitor matching each job's cadence. - Also monitor the app itself — Scheduler failures and app health are different axes, and the combination is diagnostic.
- Route alerts to Slack or email, then break a staging job on purpose and confirm the alert arrives — a fire drill for your scheduler.
Frequently asked questions
Is Heroku Scheduler guaranteed to run my jobs?
No — Heroku documents it as best-effort, with occasional skips possible, and recommends a clock process for critical jobs. No retries, no failure alerts either way.
How do I know if a Scheduler job failed?
By default you don't: the one-off dyno exits and its logs evaporate. Chain a heartbeat ping with && so success is the only thing that pings, and the monitor alerts on everything else.
Scheduler or a custom clock process?
Scheduler for skip-tolerant jobs on its fixed cadences; a clock process for reliability and finer schedules. Both need heartbeats — the clock process is its own single point of silence.
What time zone does Scheduler use?
UTC, always. Daily jobs drift an hour against local time across DST transitions.
Best-effort scheduling needs guaranteed detection
Heroku tells you, in the documentation, that Scheduler may skip your job — the platform has already told you it won't be the one to catch it. A curl chained onto each command and a 10-minute canary turn "best effort" into something you can actually run production jobs on: not because the runs became guaranteed, but because the misses became loud. Set up CronAlert and wire the canary first.
Related reading: cron job heartbeat monitoring, monitoring Celery Beat, monitoring Vercel cron jobs, monitoring GitHub Actions schedules, and batch job monitoring.