Back to blog

Comparisons · Dec 28, 2025

Why GitHub Actions cron misses and what to use instead

GitHub Actions cron is free and lives next to your code. The catch is in the docs: schedule events "may be delayed during periods of high load", and community reports show 15+ minute drift around top-of-hour. Here is the on-time pattern teams use instead.
crontap.com / blog
GitHub's own schedule event docs warn that workflows "may be delayed during periods of high load", and community reports show 15+ minute drift around top-of-hour. Here is what is actually happening, when it bites, and the workflow_dispatch + external cron pattern that gives you on-time fires with retries.

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 schedule event 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-5 workflow 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:

  1. Schedule for a quieter time. Pick :17 past the hour instead of :00. Drift drops sharply. Useful, but it does not help when the business genuinely wants the report at 09:00.
  2. 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.

  1. URL. https://api.github.com/repos/{owner}/{repo}/dispatches. Replace {owner} and {repo} with your real values.
  2. Method. POST.
  3. Headers.
    • Authorization: Bearer <your-fine-grained-token>
    • Accept: application/vnd.github+json
    • X-GitHub-Api-Version: 2022-11-28
  4. Body (JSON).
    {
      "event_type": "scheduled-run"
    }
    
    The event_type matches the types: [scheduled-run] value in the workflow's repository_dispatch trigger.
  5. Cadence. Type plain English ("every weekday at 9am") or paste a cron expression. Crontap previews the next 5 fires inline.
  6. Timezone. Pick the IANA zone that matches the schedule's intent. GitHub Actions cron is UTC only; Crontap is per-schedule.
  7. 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

Related on Crontap

From the blog

Read the blog

Guides, patterns and product updates.

Tutorials on scheduling API calls, webhooks and automations, plus deep dives into cron syntax, timezones and reliability.

Alternatives

Vercel Cron every minute: beating the Hobby hourly limit

Vercel Cron caps Hobby at hourly cadence and 5 jobs, and ties every change to a redeploy. Here is the external cron pattern teams use to ship per-minute schedules, per-IANA timezones, and one dashboard across projects without paying $20/mo per user for Pro.

Alternatives

Cloud Run cron without Cloud Scheduler

Cloud Scheduler costs $0.10 per job per month after the first 3 and asks for OIDC plus IAM bindings on every target. Here is the IAM-free pattern Cloud Run teams use to fire their .run.app URLs on a clock with one bearer token and one dashboard across every GCP project.

Alternatives

Heroku Scheduler alternative: any cron expression without the add-on

Heroku Scheduler caps you at three cadences and account-wide UTC, and spins a one-off dyno per run. Here is the external cron pattern that gives you any cron expression, per-schedule timezones, and zero per-execution dyno spin-up cost.

Guides

Running an OpenAI sentiment pipeline on a real scheduler

OpenAI batch work needs a clock, not a user session. Here is the scheduled HTTP-route pattern teams use to drain LLM batches at a sustainable rate inside OpenAI's rate limits, with per-task failure alerts.

Reference

Cron syntax cheat sheet with real-world examples

Cron syntax without the math. Every pattern you're likely to reach for (every 5 minutes, weekdays, business hours, first of the month), with a practical example and a link to a free debugger.