You added an on: schedule: trigger to a workflow, set it to fire at 09:00 UTC on weekdays, and merged it. The first week, it fired at 09:02. Then 09:14. Then 09:23. Then on the worst day, 09:38, half an hour late, well after your 9am report email was supposed to be in inboxes. You opened the runs tab expecting an outage, and instead found everything green. The workflow ran. It just did not run when you asked. That is GitHub Actions cron drift, and it is documented behavior. Here is what is going on, when it actually matters, and the pattern teams use to keep cron on time without leaving GitHub.
If you want the short version: keep your workflow, swap on: schedule: for on: workflow_dispatch:, and let Crontap fire repository_dispatch (or any HTTP target) on the cadence and timezone you actually want. You get on-time fires, automatic 5xx retries, per-IANA timezones, and centralized alerts when something breaks.
What GitHub itself says about schedule drift
The most-cited line in this whole story is in GitHub's own schedule event docs:
The
scheduleevent can be delayed during periods of high load on GitHub Actions. High load times include the start of every hour. To decrease the chance of delay, schedule your workflow to run at a different time of the hour.
That is GitHub the platform telling you, in writing, that scheduled workflows are best-effort. The recommended workaround is "do not schedule on the hour". Which is fair, but if your business actually wants the schedule to fire at 09:00 (and not 09:14 or 09:38), GitHub's recommendation is "want something else".
The GitHub community discussion thread on schedule drift is full of users reporting 15-minute, 30-minute, and occasionally hour-long delays around peak times. The runs always eventually happen. They just do not happen on time.
What "drift" looks like in practice
A few real-world shapes:
- Top-of-hour delay. A
0 * * * *workflow that runs at the top of every hour. Most fires land within 60-90 seconds of the hour. About 1 in 10 lands 5+ minutes late. About 1 in 50 lands 15+ minutes late, especially on Mondays around 14:00 UTC when the global Actions queue peaks. - 9am business-hours drift. A
0 9 * * 1-5workflow set to fire weekdays at 09:00 UTC. The 9 to 10am UTC slot is among the busiest on the platform. Drift here is the most painful kind because 09:00 is also when humans expect to see the output (a report, a notification, a metrics rollup). - Sub-5-minute? Not allowed. GitHub Actions schedule has a 5-minute minimum cadence. If you write
*/2 * * * *or* * * * *, the workflow either silently coalesces to every 5 minutes or refuses to run at all (the behavior is platform-side and changes over time; the practical takeaway is "do not bother trying"). - No retry on platform-skipped runs. If a fire is scheduled for 09:00 and the platform does not get to it before 10:00 (the next fire), the 09:00 fire is gone. GitHub does not retry; it just runs the next one when it can.
The drift is not an outage. It is just GitHub being honest that scheduled workflows are queued like every other Actions job.
Why the drift exists
GitHub Actions runs scheduled workflows in the same job queue as every push, every PR, every dispatch trigger across every repo on the platform. There is no separate "scheduled tier" with reserved capacity. When the global queue gets busy, your cron workflow waits in line behind every PR check that landed at the same moment.
That is a reasonable design choice for a free-tier-heavy platform. It is also why the drift happens: the queue has its priorities, and "the user asked for 09:00" is not always the highest one.
The two predictable ways to avoid it:
- Schedule for a quieter time. Pick
:17past the hour instead of:00. Drift drops sharply. Useful, but it does not help when the business genuinely wants the report at 09:00. - Take cron out of the queue. Trigger the workflow from outside the GitHub Actions queue, on a real scheduler. That is the rest of this post.
When GitHub Actions cron is still fine
Drift only matters if you care when the workflow fires. Plenty of cron workflows do not.
- A nightly data export at 02:00. Nobody is watching at 02:00; whether it lands at 02:14 is irrelevant.
- A weekly link-checker on Sunday morning. Drift of an hour is no business problem.
- An hourly cache warmer that just keeps a cache fresh. The cache is freshly-warmed if any fire lands; the exact second does not matter.
- A weekly dependency-update PR via dependabot or a custom Actions workflow. Same logic.
For all of these, the drift is invisible. Keep the schedule trigger. The Actions ecosystem is good and free, and the trade-off is fine.
The wedge is for the workflows where on-time matters: morning reports, end-of-day rollups, customer-facing notifications, time-sensitive integrations.
The pattern: workflow_dispatch + external cron
The shape is small. You stop using on: schedule: and start using on: workflow_dispatch: (or on: repository_dispatch:). Then you fire the dispatch from outside, on the schedule and timezone you actually want.
Crontap (cron) → HTTPS POST → GitHub repository_dispatch API → your workflow runs
GitHub still owns the runtime and the runner. Crontap owns the clock. The contract between them is one HTTPS POST per cadence with a GitHub Personal Access Token (PAT) or fine-grained token in the Authorization header.
Step 1: Convert the workflow trigger
In .github/workflows/your-workflow.yml, change:
on:
schedule:
- cron: "0 9 * * 1-5"
to:
on:
workflow_dispatch:
repository_dispatch:
types: [scheduled-run]
Both triggers stay so you can still run the workflow manually (workflow_dispatch is the "Run workflow" button in the Actions UI). The repository_dispatch trigger is the one Crontap fires.
Step 2: Mint a fine-grained personal access token
In GitHub, go to Settings, Developer settings, Personal access tokens, Fine-grained tokens, Generate new token. Scope it to the specific repo (or org) and grant only the Actions: write permission. Set the expiration to whatever your security policy allows; rotate it periodically.
A repository-level fine-grained token is much safer than a classic PAT because it is scoped to one repo with one permission and cannot accidentally do anything else with your GitHub account.
Step 3: Point Crontap at GitHub's repository_dispatch API
Head to Crontap and create a new schedule.
- URL.
https://api.github.com/repos/{owner}/{repo}/dispatches. Replace{owner}and{repo}with your real values. - Method.
POST. - Headers.
Authorization: Bearer <your-fine-grained-token>Accept: application/vnd.github+jsonX-GitHub-Api-Version: 2022-11-28
- Body (JSON).
The{ "event_type": "scheduled-run" }event_typematches thetypes: [scheduled-run]value in the workflow'srepository_dispatchtrigger. - Cadence. Type plain English ("every weekday at 9am") or paste a cron expression. Crontap previews the next 5 fires inline.
- Timezone. Pick the IANA zone that matches the schedule's intent. GitHub Actions cron is UTC only; Crontap is per-schedule.
- Failure alerts. Add an integration: email / webhook (Slack / Discord / Telegram). Crontap fires on 4xx and 5xx with the response body and timing in the payload.
Press Perform test to fire a real request before you trust the cadence. If GitHub returns 204 No Content, you are done; the workflow run will appear in the Actions tab seconds later.
Fix this in 60 seconds with Crontap. Free tier available. No credit card. Schedule your first job →
Worked example: nightly migration check at 02:00 UTC
A team runs a nightly database migration health check via a GitHub Actions workflow that runs schema diffs and posts the results to Slack. The team runs this at 02:00 UTC because the database is quiet then. Drift of even 30 minutes is OK in theory, but the same team also runs a 02:30 UTC backup that depends on the schema-diff result being recorded in their internal log first.
Old shape (on: schedule:):
on:
schedule:
- cron: "0 2 * * *"
The drift is rarely catastrophic, but on a few nights a month, the schema-diff lands at 02:34, after the backup has already started, and the dependency chain breaks. The team workaround is "the backup runs at 03:00 instead of 02:30 so we have a buffer". That is the wrong shape; they are buying margin against a platform behavior.
New shape (on: repository_dispatch: + Crontap):
on:
workflow_dispatch:
repository_dispatch:
types: [scheduled-run]
Crontap fires 0 2 * * * UTC, the API call lands at 02:00:00 to 02:00:01 every night, the schema-diff runs immediately, and the backup at 02:30 has the record it needs without the 30-minute buffer.
The migration test still runs on GitHub-hosted runners with the full Actions ecosystem (the tooling, the secrets, the log retention). Only the clock changed.
Side benefit: per-IANA timezone
GitHub Actions cron is UTC only. If your team is in Europe/London and you want a schedule at "every weekday at 09:00 my time", you have to do DST math: BST is UTC+1, GMT is UTC+0, so the workflow has to swap twice a year. Most teams just write 0 9 * * 1-5 (always UTC), accept that "09:00" means 10:00 BST in summer, and shrug.
Crontap stores timezone per schedule. Europe/London means Europe/London always, and DST is handled automatically by the scheduler. You set it once and stop thinking about it.
What you give up
Honest accounting. The external pattern is not free of trade-offs.
- The schedule no longer lives in the repo. New team members will not find it in
.github/workflows/; they have to know to look in Crontap. - You depend on one more SaaS for the cron part. Crontap going down means the schedule does not fire (Crontap has its own SLA; the Actions queue has its drift). Different failure modes.
- You manage a token. Fine-grained PATs are easier to manage than classic, but it is still one more credential that needs rotation.
For the workflows where on-time matters, the trade is usually a fair one. For the workflows where it does not, keep the schedule trigger.
FAQ
How much does GitHub Actions cron drift in practice?
GitHub's docs say schedule events "may be delayed during periods of high load". Community reports describe 5-minute drift as routine, 15-minute drift as common around top-of-hour, and 30+ minute drift on busy days. Sub-minute precision is not a guarantee the platform offers.
Will Crontap re-run a missed run?
Crontap auto-retries on 5xx responses (the GitHub API briefly returning 502 or 503, etc.) and alerts you on final failure. It does not attempt to re-run runs the platform itself missed; the on-time guarantee is that Crontap fires the dispatch on time. If GitHub is down at that moment, that is a different failure (and a much rarer one).
Can I keep the workflow file in on: schedule: AND fire it via Crontap?
You can, but it is not recommended. Both triggers will fire, you will have duplicate runs (one on time from Crontap, one drifted from the schedule), and the duplication wastes Actions minutes. Pick one.
What about scheduled workflows in private repos with limited Action minutes?
The pattern is identical. The difference is that you are now also paying Crontap for the on-time guarantee instead of consuming free Actions minutes. For most teams, the trade-off is fine; for a free-Actions-heavy public-repo team, the existing schedule trigger may be the right call regardless of drift.
Does the workflow_dispatch trigger count toward Actions usage?
Yes; every fire is a workflow run, billed the same as any other workflow run. The cost difference vs on: schedule: is zero. The wedge is on-time firing, not minutes.
Can I trigger a workflow in a different repo from Crontap?
Yes. The repository_dispatch API targets the repo specified in the URL (/repos/{owner}/{repo}/dispatches), and your fine-grained token can be scoped to multiple repos. One Crontap account can fan out to many repos via many schedules with the same shape.
References
- GitHub Actions: events that trigger workflows (schedule)
- GitHub community discussion: schedule cron drift
- GitHub repository_dispatch API docs
Related on Crontap
- Crontap vs GitHub Actions cron, side by side. The head-to-head decision page with the at-a-glance table.
- Automated data sync use case. The category spoke for recurring sync work; many teams arrive here from a drifting GH Actions cron.
- Cloud Run cron without Cloud Scheduler. Sibling cron-from-the-cloud-platform shape from the Phase 4 H batch.
