Heroku Scheduler: Best-Effort Jobs, Clock Processes, and Monitoring
Contents
Heroku Scheduler is a free add-on that runs a command in a one-off dyno every 10 minutes, every hour, or every day at a time you set in UTC. It's the quickest way to get a cron job on Heroku, and it's best-effort: Heroku says it has "no guarantee that jobs will execute at their scheduled time, or at all," that in very rare instances a job may be skipped or run twice, and that a scheduled dyno is terminated once it has run for its scheduling interval. Nothing alerts you when a run fails, is cut off, or never starts. For jobs that matter, Heroku recommends a clock process instead: one always-on dyno running a scheduling library. Heroku's behavior below was checked against devcenter.heroku.com on October 25, 2026.
How Heroku Scheduler Runs a Job#
Install the add-on and open its dashboard:
heroku addons:create scheduler:standard --app example-app
heroku addons:open scheduler --app example-appThe rest comes from the Scheduler article, last updated July 24, 2026:
- Three frequencies. Add Job takes a command, a frequency (every 10 minutes, hourly, or daily), a dyno size, and the next run time. Heroku's hourly example runs at :30. "The next run time for daily jobs is in UTC," and Heroku's advice for a local time is to add the UTC offset yourself.
- A fresh one-off dyno per run. Scheduler uses the same one-off dynos as
heroku run: your deployed code, your config vars as environment variables, and a filesystem that no other dyno sees and that is discarded afterwards. Runs appear in logs andheroku psasscheduler.1,scheduler.2, and so on. One-off dynos never restart on their own, so a run that crashes stays crashed. - The add-on is free; the dyno time isn't. It counts like any other usage, listed as the "scheduler" dyno type on the invoice and prorated to the second: about $0.01 an hour on Basic and $0.03 on Standard-1X (usage and billing, checked October 25, 2026). On Eco, scheduled dynos draw from the account's shared 1,000-hour pool, and once it's spent, "all Eco dynos on your account are forced to sleep for the rest of the month" (Eco dynos).
- Container Registry apps use the
webimage. "There is no way to specify a non-web image for task execution," so the command has to exist there.
Keep secrets out of the command field. In the Common Runtime, Heroku expands config vars "before logging the executed command to the app's log stream for audit purposes." A command typed into the dashboard as curl --user "$CRONRADAR_API_KEY:" ... writes the key into your logs and every log drain. Make the command a script in bin/ and read secrets inside it.
Heroku Says Runs Can Be Late, Skipped, or Doubled#
The Scheduler article's known-issues section reads: "Scheduler is a free add-on with no guarantee that jobs will execute at their scheduled time, or at all: In very rare instances, a job may be skipped. In very rare instances, a job may run twice." It adds that Scheduler "is known to occasionally (but rarely) miss the execution of scheduled jobs" and recommends a custom clock process where scheduled jobs are critical. The one-off dyno article says the same of the dynos: they "aren't guaranteed to handle exactly-once executions." No bound on start delay is documented.
What that means in practice:
- A skipped run leaves no evidence. No dyno starts, so there's no log line, no failed state, and no notification. Only something that expects the run can notice it's missing.
- A doubled run needs a job that tolerates it. Besides rare double runs, "two dynos running the same job may overlap for a brief time when a new one is started," because dyno termination has jitter.
flockcan't prevent that, since each dyno has its own filesystem and neither sees the other's lock file. Use a lock both can see, such as a PostgreSQL advisory lock held on the job's own database connection, or make a second run harmless. Cron job running twice covers making jobs idempotent, and distributed cron compares the locking options. - A failed run is only log output. The article describes no alert for a job that exits non-zero, is cut off, or doesn't run.
Long Jobs Are Cut Off at the Interval#
"A dyno started by scheduler will not run longer than its scheduling interval. For example, for a job that runs every 10 minutes, dynos will be terminated after running for approximately 10 minutes." The ceiling is about 10 minutes, an hour, or a day, depending on the frequency. Heroku's guidance is to run short tasks or enqueue longer ones for a worker dyno: "Anything that takes longer than a couple of minutes to complete should use a worker dyno to run."
Two platform limits apply to every one-off dyno as well:
- Daily cycling. Every dyno, one-off dynos included, is cycled once every 24 hours plus up to 216 random minutes, so none runs longer than that (dyno restarts).
- Shutdown signals. When the dyno manager stops a dyno, it sends
SIGTERMto every process in it and, 30 seconds later,SIGKILLto whatever remains, loggingR12 (Exit timeout)(dyno shutdown behavior). The Scheduler article doesn't say which signal ends a dyno at its interval.
Either way, when a job is ended from outside, a wrapper like the one below never reaches its complete or fail report. Give the job a limit of its own below the interval, so a hang ends as a failure the script can report. timeout is in coreutils on the Heroku-22, Heroku-24, and Heroku-26 stacks (stack packages) and exits 124 when it stops the command; Heroku-26, based on Ubuntu 26.04, uses the Rust rewrite (rust-coreutils 0.10.0), whose timeout returns the same code. For an hourly job, timeout -k 1m 50m sends TERM at 50 minutes and KILL a minute later if the job is still running. Cron job timeout covers the child processes timeout does and doesn't reach.
Why a Heroku Scheduler Job Didn't Run#
Start with the logs. Scheduler dynos write to the app's log stream, from state changes such as State changed from created to starting and State changed from up to complete to everything the job prints. With Heroku CLI 11.12.1, the current release on October 25, 2026, filter by process type, then check the most recent scheduler dynos:
heroku logs --process-type scheduler --num 1500 --app example-app
heroku ps --app example-appThe --ps flag in Heroku's article still works as a hidden alias (logs.ts). History is short: on Cedar, Logplex "retains the most recent 1,500 lines of your consolidated logs" for the whole app, so busy web dynos can push last night's run out of reach. Fir-generation apps have no log history at all, and heroku logs only tails (logging). If you want to look back, add a log drain or a logging add-on.
| What you find | Likely cause | Fix |
|---|---|---|
No scheduler lines around the expected time |
Scheduler skipped the run, or the 1,500 lines rolled past | Keep logs in a drain so the two can be told apart |
| The job ran hours off | The daily time was set as local time | Enter it in UTC, and recheck after daylight saving changes |
| A permission or not-found error for the command | Script not executable, not deployed, or not in web image |
git update-index --chmod=+x bin/nightly-report, commit, and deploy |
| Output stops and the dyno ends near the interval | Terminated at its interval | Shorten the job or enqueue it for a worker |
| Errors in the job's output before its dyno ends | The job failed and nobody was told | Fix the error, and report failures to something that alerts |
| Nothing runs on an Eco app late in the month | Eco dyno hours used up | heroku ps shows the hours remaining; Basic dynos have no pool |
Test a command the way Scheduler runs it with heroku run bin/nightly-report; the article says "if it works with heroku run, it will work from the scheduler." heroku run:detached bin/nightly-report runs it in the background and sends its output to the logs instead of your terminal, closer to a scheduled run.
Monitor Each Scheduler Job With Check-Ins#
Since a skipped run produces nothing to alert on, the alert has to come from the absence of a signal: the dead man's switch pattern. Each run reports start and then complete or fail to a monitor that knows the schedule, and a run that doesn't report on time turns the monitor critical. There's no CronRadar SDK for Heroku Scheduler and no auto-discovery of its jobs, so each job reports with its own HTTP check-ins, one monitor per Scheduler job.
Store the CronRadar application's API key as a config var. Setting one restarts the app's dynos and creates a release (config vars):
heroku config:set CRONRADAR_API_KEY=ck_app_12345_abcdef --app example-appCommit this script as bin/nightly-report with the executable bit, and give the Scheduler job the command bin/nightly-report, frequency Daily, next run 03:30:
#!/bin/sh
# bin/nightly-report: Heroku Scheduler runs it daily at 03:30 UTC
url="https://cron.life/api/v1/ping/nightly-report"
run_id="nightly-report-$(date +%s)-$$"
report() {
event=$1
shift
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=30 3 * * *" \
--data-urlencode "gracePeriod=900" \
--data-urlencode "run_id=$run_id" \
"$@" "$url/$event" || true
}
report start
timeout -k 1m 45m python -m reports.nightly
rc=$?
if [ "$rc" -eq 0 ]; then
report complete
elif [ "$rc" -eq 124 ]; then
report fail --data-urlencode "message=nightly report hit its 45-minute timeout"
else
report fail --data-urlencode "message=nightly report exited with code $rc"
fi
exit "$rc"- Monitoring can't break the job. Each check-in gives up after 5 seconds and ignores its own errors, and the script exits with the job's status, so a CronRadar outage changes nothing about the run.
- The key stays out of the logs. The Scheduler command is a bare path, so there's nothing for Heroku to expand, and the script never prints the key. Don't add
set -x, which would. - The monitor creates itself. Every check-in carries the schedule, so the first run creates the monitor and a deleted one comes back on the next run.
scheduleandgracePeriodapply only when a check-in creates the monitor; change an existing monitor's values in its settings. - Runs don't get mixed up. If Scheduler runs the job twice, each run reports under its own
run_id.
For set -e, traps, and logging in a longer script, see shell script best practices.
Translate the Scheduler Setting to Cron#
CronRadar reads schedule as five-field cron in UTC (schedule syntax), which matches Scheduler's own clock:
| Scheduler setting | schedule |
Notes |
|---|---|---|
| Daily, next run 03:30 | 30 3 * * * |
Daily times are already UTC |
| Hourly at :30 | 30 * * * * |
|
| Every 10 minutes | */10 * * * * |
If runs start at :05, :15, and so on, use 5-55/10 * * * * |
Heroku doesn't document which minutes a 10-minute job uses, so read them from the start timestamps in the logs. Put a constant offset in the schedule rather than in the grace period, or every real alert is delayed by it.
Grace Period and Stuck Runs#
With start reported first, the grace period only covers how late the dyno starts and reaches the script. Heroku documents no bound on that, so no value is guaranteed to cover it; choose what you'd tolerate and compare it with real start times from your logs. For daily and hourly jobs, 900 seconds is a reasonable start. For a 10-minute job, stay under the interval, for example 300 seconds: each check-in moves the expected time to the next scheduled run, so with a grace of 10 minutes or more, the next run checks in before one skipped run is ever flagged.
CronRadar checks for overdue monitors once a minute. A monitor is warning once its expected time passes and becomes critical, with an incident, only when it is past the grace period and at least two minutes late. A fail check-in alerts immediately. Alerts go to email, Slack, a webhook, Discord, or PagerDuty.
A run terminated at its interval sends start and nothing else. CronRadar flags that as stuck only when the job's next scheduled time plus the grace period passes with no other check-in, and the next run's start resets that window. A 10-minute job that's cut off every time keeps sending fresh starts and is never flagged. CronRadar has no per-job maximum-runtime setting, which is why the script carries its own timeout: a hang becomes a fail that alerts at once.
When to Use a Clock Process Instead#
Heroku recommends a custom clock process "for more reliability, control, and visibility" when scheduled jobs are critical, and for schedules Scheduler can't express, such as "three times a day, every two hours, or even every 5 seconds" (custom clock processes). A clock is a Procfile process type that stays running and fires jobs with a scheduling library.
| Heroku Scheduler | Clock process | |
|---|---|---|
| Schedules | Every 10 minutes, hourly, daily | Whatever the library supports |
| Runs in | A new one-off dyno per run | One always-on dyno |
| Cost | Dyno time while a job runs | A dyno around the clock |
| Documented misses | Rare skips and double runs | Runs due while the dyno restarts |
| Long jobs | Terminated at the interval | Run until the dyno restarts |
Heroku's Python example uses APScheduler 3.x and pins APScheduler>=3.10,<4.0; 3.11.3 is the current release, and 4.0 is still in alpha (PyPI, checked October 25, 2026). The Procfile line:
clock: python clock.pyThis clock.py runs a check-in wrapper like the one above every 5 minutes. bin/sync-orders reports to its own monitor with schedule=*/5 * * * *, gracePeriod=120, and timeout -k 30s 4m:
# clock.py
import subprocess
from apscheduler.schedulers.blocking import BlockingScheduler
sched = BlockingScheduler(timezone="UTC")
@sched.scheduled_job("cron", minute="*/5", misfire_grace_time=60)
def sync_orders():
# The script sends its own start, complete, and fail check-ins.
subprocess.run(["bin/sync-orders"], check=False)
sched.start()Deploy, then scale the clock to one dyno:
heroku ps:scale clock=1 --app example-app- Run exactly one clock dyno. Heroku: "If you run two, the work will be duplicated." Every job would fire once per clock dyno.
- Restarts lose runs. The clock restarts on every deploy, every config var change, and the daily cycle. APScheduler's default job store is in memory, so a run due while the clock is down simply doesn't happen. Heroku says some startup logic "will need to exist" to make sure "a job interval wasn't skipped during the dyno restart." Whatever slips through shows up as a missed check-in.
- APScheduler skips with only a warning. In 3.11.3, a run more than
misfire_grace_timelate, 1 second by default, is skipped with a "was missed by" warning, and a run due while the previous one is still going is skipped becausemax_instancesdefaults to 1 (base.py). Both are log lines. Through the check-ins, the first shows up as a missed run, and the second as stuck once the next run's time plus the grace period passes while the long run is still going. - Heavy work belongs on a worker. Heroku advises that the clock only enqueue jobs and a worker dyno run them. This example runs a short script inside the clock dyno; if you enqueue instead, send the check-ins from the worker, where the job succeeds or fails.
The Scheduler article also points to the Dynos category of the Elements Marketplace for add-ons that schedule tasks. Cron jobs on Render, Railway, and Fly.io compares how those platforms run scheduled jobs.
Frequently Asked Questions#
Is Heroku Scheduler reliable?#
It's best-effort by Heroku's own description: neither the time nor the run itself is guaranteed, and in very rare instances a job is skipped or runs twice. That's fine for work where an occasional miss is tolerable. For critical jobs, Heroku recommends a custom clock process, and with either, only an external check-in monitor tells you a run didn't happen.
Why didn't my Heroku Scheduler job run?#
Look for the run with heroku logs --process-type scheduler. No trace means Scheduler skipped it or Logplex's 1,500-line buffer has already moved past it. If it ran, check for a daily time entered as local time instead of UTC, a script without execute permission, a job terminated at its interval, or an Eco account out of dyno hours.
Can Heroku Scheduler run a job every minute?#
No. The shortest frequency is every 10 minutes, and the others are hourly and daily. For every minute, every 5 minutes, or something like three times a day, run a clock process on a single dyno with a scheduling library such as APScheduler.
What time zone does Heroku Scheduler use?#
UTC. The daily run time is entered in UTC, and Heroku's advice for a local time is to add the UTC offset. Because the time is fixed in UTC, a job meant for a local hour moves by an hour locally when daylight saving starts or ends.
How do I alert when a Heroku scheduled job fails?#
Heroku doesn't alert on a scheduled job that fails, is terminated, or is skipped. Make the Scheduler command a script that reports start, then complete or fail, to a monitor such as CronRadar, which alerts immediately on a fail and goes critical when an expected run doesn't report within its grace period. Put a timeout inside the script so a hang ends in a reported failure before Heroku terminates the dyno.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.