My hourly price scraper had one job: run at minute zero, every hour, forever. It ran on a GitHub Actions cron schedule, cost me nothing, and I hadn't thought about it for months.
Then I noticed a gap in the chart. I pulled two weeks of run history and counted. 336 hourly slots. 319 runs. Seventeen never happened. No failure, no red X, no email. They were just gone. The runs that did fire started a median of 11 minutes late, and the worst one started 47 minutes after its slot.
Nothing was broken. The GitHub Actions cron trigger did exactly what its documentation says it does. I had just never read that part.
TL;DR
- GitHub Actions cron (
on: schedule) is best-effort. Runs can start late during high load, and if the load is high enough, queued scheduled runs can be dropped entirely. - The start of every hour is the worst time to schedule. Everyone writes
0 * * * *. Moving to an odd minute like17cut my delays sharply. - In public repos, scheduled workflows are disabled automatically after 60 days without repository activity. The cron line stays in your YAML. It just stops running.
- Schedules only run from the default branch, using the workflow file from its latest commit. Editing the cron on a feature branch does nothing.
- If a missed run costs you money or data, trigger the workflow from an external scheduler via
repository_dispatchorworkflow_dispatch, and make the job catch up from a watermark instead of assuming "last hour".
Why does my GitHub Actions cron run late?
Because scheduled workflows go into a shared queue, and that queue is busiest exactly when your cron wants to fire. GitHub's docs say it plainly: the schedule event can be delayed during periods of high load, high load times include the start of every hour, and if load is high enough some queued jobs may be dropped.
Think about what everyone writes:
on: schedule: - cron: "0 * * * *" # hourly - cron: "0 0 * * *" # nightly Every tutorial, every Stack Overflow answer, every "run my backup nightly" repo. Millions of workflows want minute zero. Your job is in that pile.
How do I measure how late my scheduled runs actually are?
Use gh run list filtered to the schedule event and compare each run's createdAt to the slot it was supposed to fill. This is the exact script I used.
gh run list \ --workflow scrape.yml \ --event schedule \ --limit 400 \ --json createdAt \ > runs.json Then a few lines of Python to bucket runs into their intended hourly slot:
import json from datetime import datetime, timedelta, timezone runs = json.load(open("runs.json")) starts = sorted( datetime.fromisoformat(r["createdAt"].replace("Z", "+00:00")) for r in runs ) # Assume each run belongs to the slot at or before it (cron "0 * * * *") delays = [] slots_seen = set() for s in starts: slot = s.replace(minute=0, second=0, microsecond=0) slots_seen.add(slot) delays.append((s - slot).total_seconds() / 60) first, last = min(slots_seen), max(slots_seen) expected = int((last - first) / timedelta(hours=1)) + 1 delays.sort() print(f"expected slots: {expected}") print(f"runs: {len(slots_seen)}") print(f"missing: {expected - len(slots_seen)}") print(f"median delay: {delays[len(delays)//2]:.1f} min") print(f"worst delay: {delays[-1]:.1f} min") If your cron is 17 * * * *, change the slot math to subtract 17 minutes first. And if a delay ever exceeds your interval, a late run can land in the next slot's bucket, so treat the "missing" count as a floor.
My output for 14 days on 0 * * * *:
expected slots: 336 runs: 319 missing: 17 median delay: 11.2 min worst delay: 47.0 min Your numbers will differ. The point is that you can get them in two minutes, and you probably should before trusting a schedule with anything important.
Does changing the cron minute actually help?
In my case, yes, by a lot. I changed one character group:
on: schedule: - cron: "17 * * * *" Over the next 14 days the median delay dropped to under 3 minutes and I counted 2 missing slots instead of 17. That is one repo, one stretch of time, not a benchmark. But it matches the documented mechanism: the pileup is at the top of the hour, so stepping off it means less competition.
Rules I now follow:
- Never use minute
0. Also avoid30,15and45, which are the second tier of "round number" crons. - Pick something ugly, like
17,41, or53. - For nightly jobs, avoid
0 0 * * *. Midnight UTC is the most popular slot of the day.23 3 * * *works just as well for a backup. - Remember the 5-minute floor. The shortest interval GitHub supports is every 5 minutes.
* * * * *will not run every minute. - Cron is evaluated in UTC by default. If you meant "9am my time", do the conversion in the YAML comment so future you knows.
Why did my scheduled workflow stop running completely?
If your repo is public, GitHub disables scheduled workflows automatically when there has been no repository activity for 60 days. This one cost me more than the delays did.
A different side project, a tiny public repo that rebuilt a static data file every morning, had been "done" for months. No commits. One day the data was three weeks stale. The workflow file was still there, cron line untouched. The Actions tab showed the workflow as disabled.
GitHub does notify you before this happens, but that email landed in the same pile as every other notification, and I didn't read it.
Check and fix it from the CLI:
# List workflows and their state gh workflow list --all # Re-enable a disabled one gh workflow enable scrape.yml gh workflow list --all includes disabled workflows, which the default listing hides. If you see a state like disabled_inactivity, that is the 60-day rule.
People work around this with "keepalive" actions that push an empty commit every few weeks. That works, but it pollutes your history with commits that exist only to fool a timer. For a repo where the scheduled job is the whole point, I'd rather move the trigger outside GitHub (see below) and stop depending on repository activity at all.
Why doesn't my cron change take effect on my branch?
Scheduled workflows only run on the default branch, using the workflow file from the latest commit there. You can edit the cron on feature/new-schedule all day and nothing will fire from it.
Two practical consequences:
- Testing a schedule means merging it. Add
workflow_dispatch:next toschedule:so you can trigger the same workflow by hand while developing. - Renaming your default branch or changing which branch is default changes which file the scheduler reads. If the new default doesn't have the workflow, the schedule disappears.
on: schedule: - cron: "17 * * * *" workflow_dispatch: {} # lets you run it manually from any branch What should I use instead when every run matters?
Keep GitHub Actions as the runner, but move the clock somewhere reliable. An external scheduler (a cron on a small VPS, a serverless scheduled function, any scheduler you already run) hits the GitHub API, and GitHub starts the workflow from an API event instead of its own cron queue.
The workflow listens for a dispatch:
on: repository_dispatch: types: [hourly-tick] workflow_dispatch: {} The external cron sends it:
curl -sS -X POST \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/repos/OWNER/REPO/dispatches \ -d '{"event_type":"hourly-tick"}' Like schedule, repository_dispatch only triggers workflows on the default branch. Use a fine-grained token scoped to that one repository.
This sidesteps the top-of-hour pileup and the 60-day inactivity rule, because nothing about it depends on GitHub's cron. It does not make Actions runners infinitely available, so the second half of the fix still matters.
How do I make a scheduled job survive missed runs?
Stop writing jobs that assume "the previous run happened exactly one interval ago". Store a watermark, and process everything since it.
The fragile version:
since = now - timedelta(hours=1) # silently loses data if a run was dropped The robust version:
since = read_watermark() # e.g. a file committed to a data branch, or a row in your DB items = fetch_changes(since=since, until=now) process(items) write_watermark(now) With a watermark, a late run just processes a bigger window, and a dropped run gets absorbed by the next one. Once I made that change, the 17 missing slots in my scraper would have cost me zero data instead of 17 hours of it.
Also add concurrency so a very late run and an on-time run don't overlap and both write the watermark:
concurrency: group: scraper cancel-in-progress: false The checklist I run on every scheduled workflow now
- Cron minute is not
0,15,30or45. -
workflow_dispatchis present for manual runs. - The job uses a watermark, not "now minus interval".
-
concurrencyis set. - If the repo is public and quiet, the trigger is external or I've accepted the 60-day rule.
- I've run the
gh run listscript once and looked at real delays.
So is GitHub Actions cron reliable?
GitHub Actions cron is reliable enough for jobs where "roughly on schedule, usually" is fine, and wrong for anything where a missed or late run loses money or data. The schedule trigger is documented as best-effort: it runs late under load, the start of every hour is the most congested time, queued runs can be dropped, it only reads the default branch, and in public repos it switches itself off after 60 days of inactivity. Move your cron off minute zero, add a watermark so missed runs catch up, and if every run truly matters, fire the workflow from an external scheduler through repository_dispatch instead of trusting GitHub's clock.
Written by the developer behind Preterview, an interview prep platform.