DEV Community

jidonglab
jidonglab

Posted on

GitHub Actions Cron Was Late 30 Nights Straight, Then Skipped One

My nightly digest job was scheduled for 0 0 * * *. It never once ran at midnight. Over 30 nights it started anywhere from 6 to 51 minutes late, and on night 23 it didn't run at all. No failed run, no red X, no email. The run simply doesn't exist in the history.

I only noticed because a reader asked why Tuesday's items were missing from the digest. That's the GitHub Actions cron trap: the schedule looks like a promise and behaves like a suggestion. And my job's logic quietly assumed the promise.

Here's what the schedule trigger actually guarantees, what it doesn't, and the small change that made my job stop losing data.

TL;DR

  • GitHub Actions cron is best-effort. Scheduled runs are queued, and GitHub's docs say they can be delayed during high load, with the start of every hour among the busiest times.
  • Runs can be dropped entirely. If load is high enough, queued scheduled jobs may never start. You get no failed run to look at.
  • Avoid minute 0. Pick an odd minute like 37 to dodge the top-of-hour pileup.
  • Don't compute "the last 24 hours" from the clock. Use a watermark (the last successful run's timestamp) so a late or missing run causes overlap instead of a gap.
  • Public repos with no activity for 60 days get their scheduled workflows disabled. Re-enable with gh workflow enable, and use an external heartbeat to catch it.

Why does my GitHub Actions cron job run late?

Because schedule doesn't start a job at a time. It enqueues a run, and that queue is shared with a huge number of other repos that also typed 0 0 * * *. GitHub's own documentation warns that scheduled events can be delayed during periods of high load, and names the start of every hour as a high-load time.

Think about how many people write 0 * * * * or 0 0 * * * without a second thought. Midnight UTC is basically a stampede.

So "late" is normal. My delays weren't a bug in my workflow; they were the scheduler working as documented.

How do I measure my own cron delay?

Use gh to pull the creation time of every scheduled run and see how far past the hour each one landed. If your cron fires at minute 0, minutes past the hour is the delay:

gh run list \
  --workflow nightly-digest.yml \
  --event schedule \
  --limit 60 \
  --json createdAt \
  --jq '.[] | "\(.createdAt)  +\((.createdAt | fromdateiso8601) % 3600 / 60 | floor)m"'
Enter fullscreen mode Exit fullscreen mode

My output looked like this (trimmed):

2026-08-29T00:51:08Z  +51m
2026-08-28T00:19:44Z  +19m
2026-08-26T00:33:02Z  +33m
2026-08-25T00:06:57Z  +6m
Enter fullscreen mode Exit fullscreen mode

Spot the problem? There's no 2026-08-27. That's the dropped night. Missing rows are the only evidence you'll get, so count the dates, not just the delays.

Two notes on reading this:

  • createdAt is when GitHub created the run, which is where the scheduling delay shows up. startedAt minus createdAt is runner queue time on top of that.
  • Your numbers will differ from mine. That's the point of running it on your repo instead of trusting anyone's chart, including this one.

Can GitHub Actions skip a scheduled run completely?

Yes. The docs say that if load is high enough, some queued scheduled jobs may be dropped. A dropped run doesn't fail, so it doesn't trigger failure notifications. It just isn't there.

This is what actually hurt me. A late run is an annoyance. A missing run is silent data loss, if your job's logic assumes it ran yesterday.

Why did a late run lose data instead of just arriving late?

Because my job did this:

SINCE=$(date -u -d '24 hours ago' +%FT%TZ)
./build-digest.sh "$SINCE"
Enter fullscreen mode Exit fullscreen mode

That looks reasonable and is subtly wrong in two directions:

  1. Jitter makes holes. If Monday's run started at 00:51 and Tuesday's at 00:06, Tuesday asks for "since Monday 00:06". Fine. But reverse it: Monday at 00:06, Tuesday at 00:51. Tuesday asks for "since Monday 00:51", and anything from 00:06 to 00:51 Monday was never covered by either run.
  2. A dropped run makes a 24-hour hole. Night 23 never ran, so night 24 asked for its own 24 hours and the previous day's items fell through the floor.

The clock-relative window assumes runs are exactly 24 hours apart. The scheduler never promised that.

How do I make a scheduled workflow tolerate delays and dropped runs?

Stop asking "what happened in the last 24 hours?" and start asking "what happened since the last time I succeeded?" That's a watermark. The nice part: GitHub already stores one for you, in the run history.

name: nightly-digest

on:
  schedule:
    - cron: '37 23 * * *'   # off the hour, UTC
  workflow_dispatch:         # manual backfill button

permissions:
  actions: read
  contents: write

concurrency:
  group: nightly-digest
  cancel-in-progress: false

jobs:
  digest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Compute window from last success
        env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}
        run: |
          SINCE=$(gh run list --workflow nightly-digest.yml --status success \
            --limit 1 --json createdAt --jq '.[0].createdAt // empty')
          SINCE=${SINCE:-$(date -u -d '25 hours ago' +%FT%TZ)}
          echo "SINCE=$SINCE" >> "$GITHUB_ENV"
          echo "UNTIL=$(date -u +%FT%TZ)" >> "$GITHUB_ENV"

      - run: ./scripts/build-digest.sh "$SINCE" "$UNTIL"
Enter fullscreen mode Exit fullscreen mode

What each piece buys you:

  • --status success as the watermark. The current run is still in progress, so it isn't counted. If yesterday's run was dropped, the last success is two days back, and the window stretches to cover both days automatically.
  • createdAt is earlier than the previous run's actual cutoff, so windows overlap slightly. That's deliberate. Overlap is cheap if your processing is idempotent (dedupe on an item ID). Gaps are silent.
  • 37 instead of 0. Not a magic number, just not the minute everyone else picked. My delays shrank after the move, but treat that as one repo's anecdote and measure yours.
  • workflow_dispatch. When a night does go missing, you can trigger a run by hand and the watermark backfills the gap for you.
  • concurrency without cancel. A late scheduled run and a manual backfill won't trample each other.

If your job doesn't have natural IDs to dedupe on, the fallback is to write the UNTIL value somewhere durable (a committed file, a release asset, your own database) and read it back as the next SINCE.

Why did my GitHub Actions cron stop running entirely?

Two common reasons, both documented, both silent from inside the workflow:

1. The 60-day inactivity rule. In a public repository, scheduled workflows are automatically disabled when there has been no repository activity for 60 days. A side project that "just runs" is exactly the kind of repo that goes 60 days without a commit. Turn it back on with:

gh workflow enable nightly-digest.yml
Enter fullscreen mode Exit fullscreen mode

2. The schedule only reads the default branch. Scheduled workflows run on the latest commit of the default branch. Editing the cron line on a feature branch does nothing until it's merged. I lost an evening to this once, staring at a schedule that "should have fired".

Neither problem can be detected by the workflow itself, because a workflow that isn't running can't report that it isn't running. The fix is a dead man's switch: have the last step ping an external heartbeat URL (Healthchecks.io and similar services do this) and let that service alert you when a ping doesn't arrive.

      - name: Heartbeat
        if: success()
        run: curl -fsS -m 10 --retry 3 "${{ secrets.HEARTBEAT_URL }}"
Enter fullscreen mode Exit fullscreen mode

Set the grace period generously. Given the delays above, an alert at "5 minutes late" will page you every night for nothing.

Other GitHub Actions cron gotchas worth knowing

  • Cron times are UTC. 0 9 * * 1-5 is not 9 AM for most of the planet, and the meaning shifts by an hour twice a year if you live somewhere with daylight saving time.
  • The shortest interval is every 5 minutes. * * * * * won't give you one run per minute.
  • Notifications go to whoever last touched the cron line. If the person who edited the schedule leaves the project, failure emails go with them.
  • Quote your cron string. * is meaningful in YAML, so cron: '*/15 * * * *' with quotes avoids parse surprises.

When should I not use the schedule trigger at all?

When the timing is the product. If something must happen within a minute of 09:00, or a missed run costs money, GitHub Actions cron is the wrong tool. Use a real scheduler (a cloud scheduler, a server's crontab, a queue with delayed jobs) and have it call the workflow_dispatch API if you still want the work to execute in Actions. You get precise timing from the scheduler and keep your existing workflow.

For digests, cleanups, dependency checks, and reports where "sometime in the next hour" is fine, schedule is great. Just write the job as if it might run late, run twice, or not run at all, because all three will happen.

So, is GitHub Actions cron reliable?

GitHub Actions cron is reliable at eventually running most of your scheduled jobs, not at running them on time or running every one. Runs are queued and delayed under load (the top of the hour is the worst), occasionally dropped with no failed run to show for it, and disabled after 60 days of inactivity in public repos. Schedule on an off-hour minute, compute your work window from the last successful run instead of the clock, make processing idempotent so overlap is harmless, keep a workflow_dispatch trigger for backfills, and use an external heartbeat so you hear about the night that never ran.


Written by the developer behind Preterview, an interview prep platform.

Top comments (1)

Collapse
 
beusebiu profile image
Eusebiu Balan •

Night 23 would worry me more than the 51 minutes. A late run you notice. A run that never existed you only find when a reader asks where Tuesday went.

My nightly jobs are on Laravel's scheduler on the app's own server, and they still need something outside it that complains when one doesn't show up.