Why scheduled runs go missing
GitHub's own documentation for the schedule event names the causes:
- Load. “The schedule event can be delayed during periods of high loads of GitHub Actions workflow runs. High load times include the start of every hour. If the load is sufficiently high enough, some queued jobs may be dropped.” Delayed means late; dropped means the run never happens.
- Inactivity. In a public repository, scheduled workflows are automatically disabled when no repository activity has occurred in 60 days.
- The default branch. Scheduled workflows run only on the default branch, from its latest commit. A schedule added on a feature branch, or a workflow file renamed on main, simply stops firing.
- Daylight saving time. For a schedule with a time zone that observes it, runs in a skipped hour move to the next valid time.
This is not hypothetical. Threads such as “Scheduled GitHub Actions workflows are delayed by several hours or missing completely” and “Scheduled GitHub Actions workflows intermittently delayed or missing” have kept appearing in GitHub's community discussions through 2026.
Why nobody notices
Notifications for workflow runs fire on a run's result. A dropped or disabled schedule produces no run, so there is no result to notify about. The nightly backup, the data export or the certificate renewal just stops, and the first sign is whatever it was protecting going wrong.
Watching for an absence needs something outside GitHub that expects to hear from the job and complains when it does not. That is a heartbeat check, also called a dead man's switch.
Add a heartbeat step
In RealUptime Monitor, add a monitor with Check type set to Heartbeat (cron). Saving shows its ping URL once. Store it as a repository secret named REALUPTIME_PING_URL, because the URL is the credential. Then add two steps at the end of the job:
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
jobs:
nightly:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./scripts/nightly.sh
- name: Heartbeat
if: success()
run: curl -fsS --retry 3 "${{ secrets.REALUPTIME_PING_URL }}"
- name: Report failure
if: failure()
run: curl -fsS --retry 3 "${{ secrets.REALUPTIME_PING_URL }}/fail"The first step runs only when everything before it succeeded, so a broken run never sends an all-clear. The second sends a fail ping, which marks the monitor down at once with “run failed” wording instead of waiting for the deadline. If the run never starts, neither step runs, and the monitor goes down when the deadline passes. The cron minute above is 17 rather than 0 because GitHub recommends avoiding the top of the hour.
Heartbeats are on every plan, Free included, and count as one of your monitors. For run durations and stuck runs, add a start ping as described in reporting failed and stuck cron runs.
Pick the interval and grace
Set the expected interval to the schedule's period: 86,400 seconds for a daily job, 3,600 for an hourly one. Then set a grace period that covers GitHub's normal lateness plus your job's runtime. For a daily job, an hour or two of grace keeps a late start from paging anyone while still catching a skipped day the same morning.
The longest interval a heartbeat accepts is 24 hours, so a weekly schedule needs a different approach, for example a small daily workflow whose only job is to ping.
Other schedulers
The same pattern, written for each scheduler's own syntax:
The account that holds the heartbeat also includes Status for a public status page, Errors for the exception a job throws, and Outages for checking whether GitHub itself is having a bad day.
Sources
All read on November 1, 2026.