DEV Community

Your GitHub Actions cron fires less often than you declared: what we measured and how to design for it

We run an automated publishing pipeline entirely on GitHub Actions cron schedules - no server, no queue, just workflows that wake up, do one thing, and commit the result. It mostly works. But there is one behaviour of scheduled workflows that the docs mention in a single quiet sentence and that will silently halve your job frequency if you design around the cron expression instead of around reality: Scheduled workflows do not fire as often as you declare. What we measured We had a feedback-watcher workflow declared at four runs per hour: on: schedule: - cron: '7,22,37,52 * * * *' Measured over days, it actually fired one to two times per hour - not four, and not at the declared minutes. Roughly hourly on most days, at inconsistent offsets from the declared slots. We later redeclared it at two runs per hour (7,37 * * * * ) - measured result: still one to two runs per hour. The declared frequency changed by 2x; the delivered frequency barely moved. This is not an outage and not a misconfiguration. GitHub's own documentation says the schedule event can be delayed during periods of high load, and that high load times include the start of every hour - which is precisely where naive cron expressions cluster - and adds: "If the load is sufficiently high enough, some queued jobs may be dropped." What the docs understate is the magnitude: in our observation, on a private repo, "delayed" in practice meant "throttled to a fraction of the declared rate, indefinitely." What this breaks The failure mode is subtle because nothing goes red. Every run that happens succeeds. The runs that don't happen leave no trace - no log, no failure email, nothing. You only notice if something downstream depends on the frequency: - We had promised a "reply within 15 minutes" SLA on incoming feedback, initially backed by the 4x/hour schedule. The schedule couldn't hold it, so for a while we ran a local 15-minute scheduler as the primary path and kept the workflow as fallback. When we later relaxed the promise to an hour, the local job stopped earning its keep and the workflow became the only path again. - A pacing job "runs daily at 08:30" - except on days when it fires at 09:10, and any consumer that reads its output at 09:00 sees stale state and makes decisions on it. Design rules that survive the throttling After getting burned, we rebuilt around four rules: 1. Declare intent, but design for the floor. Treat the cron expression as an upper bound and ask: does the system still behave correctly if this fires once per hour? Once per day, if it fires two hours late? If the answer is no, the design is wrong, not the schedule. 2. Make every run idempotent and self-contained. Each of our jobs re-derives "what needs doing" from committed state (JSONL ledgers in the repo), does at most one unit of work, and commits the updated ledger. A missed run costs latency, never correctness. A doubled run (it happens) costs nothing, because the ledger check makes the second run a no-op. 3. Don't encode business timing in cron minutes. Our daily article publisher doesn't publish "at 13:00 UTC" - it publishes "the oldest unpublished item, if none was published this UTC day." The schedule is just a heartbeat that triggers the check. If GitHub fires it at 13:40, the behaviour is identical. 4. Move real deadlines out of scheduled workflows. Anything with an actual latency promise now sits behind a stricter trigger, or the promise itself gets renegotiated to something the delivered rate can honor. Ours became one hour - a product decision, not a capitulation - and the cron stayed declared at 2x/hour: headroom above the promise, so the throttled rate still meets it. The check worth running on your own repo Pull the actual run timestamps and compare to your declaration: gh run list --workflow=yourjob.yml --limit 50 \ --json createdAt --jq '.[].createdAt' Count runs per hour over a day. If you declared four and got one, nothing is broken - you have simply been reading the cron expression as a contract when it was always a request. Written while building Rulestack - an automated content pipeline that runs on nothing but GitHub Actions and committed JSONL state. Field notes from running it go up daily at @ai-shop.bsky.social on Bluesky. Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.