# Heroku Scheduler: Best-Effort Jobs, Clock Processes, and Monitoring

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/heroku-scheduler

> How Heroku Scheduler runs jobs in one-off dynos, why runs can be skipped, doubled, or cut off, when to use a clock process, and how to get alerted.

**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:

```bash
heroku addons:create scheduler:standard --app example-app
heroku addons:open scheduler --app example-app
```

The rest comes from the [Scheduler article](https://devcenter.heroku.com/articles/scheduler), 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](https://devcenter.heroku.com/articles/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 and `heroku ps` as `scheduler.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](https://devcenter.heroku.com/articles/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](https://devcenter.heroku.com/articles/eco-dyno-hours)).
- **Container Registry apps use the `web` image.** "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. `flock` can'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](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS) held on the job's own database connection, or make a second run harmless. [Cron job running twice](https://cronradar.com/blog/cron-job-running-twice) covers making jobs idempotent, and [distributed cron](https://cronradar.com/blog/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](https://devcenter.heroku.com/articles/background-jobs-queueing): "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](https://devcenter.heroku.com/articles/dyno-restarts#daily-restarts)).
- **Shutdown signals.** When the dyno manager stops a dyno, it sends `SIGTERM` to every process in it and, 30 seconds later, `SIGKILL` to whatever remains, logging `R12 (Exit timeout)` ([dyno shutdown behavior](https://devcenter.heroku.com/articles/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`](https://man7.org/linux/man-pages/man1/timeout.1.html) is in coreutils on the Heroku-22, Heroku-24, and Heroku-26 stacks ([stack packages](https://devcenter.heroku.com/articles/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](https://github.com/uutils/coreutils/blob/0.10.0/src/uu/timeout/src/status.rs). 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](https://cronradar.com/blog/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:

```bash
heroku logs --process-type scheduler --num 1500 --app example-app
heroku ps --app example-app
```

The `--ps` flag in Heroku's article still works as a hidden alias ([logs.ts](https://github.com/heroku/cli/blob/v11.12.1/src/commands/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](https://devcenter.heroku.com/articles/logging)). If you want to look back, add a [log drain](https://devcenter.heroku.com/articles/log-drains) 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](https://cronradar.com/blog/cron-dead-mans-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](https://devcenter.heroku.com/articles/config-vars)):

```bash
heroku config:set CRONRADAR_API_KEY=ck_app_12345_abcdef --app example-app
```

Commit 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:

```bash
#!/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. `schedule` and `gracePeriod` apply 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](https://cronradar.com/blog/cron-shell-script-best-practices).

### Translate the Scheduler Setting to Cron

CronRadar reads `schedule` as five-field cron in UTC ([schedule syntax](https://docs.cronradar.com/schedules)), 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](https://devcenter.heroku.com/articles/scheduled-jobs-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](https://devcenter.heroku.com/articles/clock-processes-python) 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](https://pypi.org/project/APScheduler/), checked October 25, 2026). The Procfile line:

```text
clock: python clock.py
```

This `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`:

```python
# 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:

```bash
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_time` late, 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 because `max_instances` defaults to 1 ([base.py](https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/schedulers/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](https://cronradar.com/blog/paas-cron-jobs) 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.
