# GitHub Actions Schedule: Delays, Skipped Runs, and Monitoring

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/github-actions-scheduled-workflows

> How the GitHub Actions schedule works: cron syntax, UTC and timezone, why runs start late or get dropped, why schedules get disabled, and how to get alerted.

**GitHub Actions runs a workflow on a schedule when the workflow file has `on: schedule` with a five-field POSIX cron expression, such as `cron: '17 3 * * *'`.** Schedules run in UTC unless the entry sets a `timezone`, no more often than every 5 minutes, and only from the workflow file on the default branch. They aren't punctual: GitHub says scheduled runs can be delayed under load, especially at the start of every hour, that some may be dropped, and that public repositories lose their schedules after 60 days without activity. GitHub's behavior below was checked against docs.github.com on October 25, 2026.

## How `on: schedule` Works

A scheduled workflow lists one or more cron expressions under `on.schedule`:

```yaml
on:
  schedule:
    - cron: '17 3 * * *' # 03:17 UTC every day
  workflow_dispatch: # also allows manual runs
```

The rules, from the [schedule event reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule):

- **Five fields.** Minute, hour, day of month, month, and day of week, with `*`, `,`, `-`, and `/`, plus month and weekday names (`JAN-DEC`, `SUN-SAT`). GitHub "does not support the non-standard syntax `@yearly`, `@monthly`, `@weekly`, `@daily`, `@hourly`, and `@reboot`." [Cron expression syntax](https://cronradar.com/blog/cron-expression) compares this dialect with Kubernetes, Quartz, and the rest.
- **UTC unless you set `timezone`.** "By default, scheduled workflows run in UTC." A `timezone` key next to `cron` takes an IANA name such as `America/New_York`. In a zone with daylight saving, "scheduled workflows in skipped hours advance to the next valid time. For example, a 2:30 AM schedule advances to 3:00 AM." The page says nothing about the hour that repeats in autumn; [cron and daylight saving time](https://cronradar.com/blog/cron-daylight-saving-time) compares how schedulers handle both.
- **Every 5 minutes at most.** "The shortest interval you can run scheduled workflows is once every 5 minutes."
- **Default branch only.** Scheduled workflows "run on the latest commit on the default branch", and the event fires only "if the workflow file exists on the default branch."

Several entries can trigger one workflow. `github.event.schedule` holds the expression that fired, so a step can run on one schedule only:

```yaml
on:
  schedule:
    - cron: '17 3 * * 1-5'
    - cron: '47 11 * * 6'

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - name: Weekly cleanup, Saturdays only
        if: github.event.schedule == '47 11 * * 6'
        run: ./scripts/weekly-cleanup.sh
      - run: ./scripts/sync.sh
```

Lint workflow files before they merge. [actionlint 1.7.12](https://github.com/rhysd/actionlint/blob/v1.7.12/docs/checks.md#check-cron-syntax-and-timezone) reports a cron without exactly five fields, one that runs more often than every 5 minutes, and a `timezone` that isn't a valid IANA name.

## Scheduled Runs Start Late, and Some Never Start

GitHub's note on the schedule event is direct: "The `schedule` event can be delayed during periods of high loads of GitHub Actions workflow runs. High load times include the start of every hour. If the load is sufficiently high enough, some queued jobs may be dropped." It gives no upper bound on the delay and describes no replay of a dropped run, so after a drop, the next run is simply the next scheduled time. GitLab, by contrast, documents where its delay comes from: a worker that queues due pipelines every 5 minutes on GitLab.com and every 10 minutes by default on self-managed instances ([GitLab scheduled pipelines](https://cronradar.com/blog/gitlab-scheduled-pipelines)).

**Schedule off the hour.** GitHub's own advice is to "schedule your workflow to run at a different time of the hour." Use `17 3 * * *` rather than `0 3 * * *`, and `7/15 * * * *` (minutes 7, 22, 37, and 52) rather than `*/15 * * * *`.

**Measure your delays.** Compare when GitHub created each scheduled run with the time in its cron expression. For the daily `17 3 * * *` workflow, this prints how many minutes late each of the last 30 scheduled runs was created, using [`gh run list`](https://cli.github.com/manual/gh_run_list):

```bash
gh run list --workflow nightly-report.yml --event schedule --limit 30 \
  --json createdAt \
  --jq '.[].createdAt | fromdateiso8601 | (. % 86400 - (3 * 3600 + 17 * 60)) / 60 | floor'
```

A day with no run in the list is a day nothing ran. With several schedules, print `github.event.schedule` in the first step so each run's log says which entry it belongs to.

## Why a Scheduled Workflow Stops Running

A delay ends with a late run. These causes end with no runs at all:

| Cause                                                                 | What you see                                               | Fix                                                                                    |
| --------------------------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| The schedule exists only on another branch                            | No scheduled runs                                          | Merge it to the default branch. Changing the default branch moves the schedule with it |
| Public repository with no activity for 60 days                        | Workflow state `disabled_inactivity`                       | Enable the workflow, or have a user with write access commit a change to its cron      |
| Fork of a public repository                                           | `disabled_fork`                                            | Enable it in the fork, if the fork should run it                                       |
| Someone disabled the workflow                                         | `disabled_manually`                                        | Enable it                                                                              |
| The workflow's actor account was suspended, deleted, or deprovisioned | State `active` and manual runs work, but no scheduled runs | A user with write access commits a change to the cron                                  |
| Actions disabled for the repository, organization, or enterprise      | No workflows run at all                                    | Change the policy in settings                                                          |
| Private repository past its included minutes                          | Usage blocked                                              | Add a payment method, or raise the budget that stopped usage                           |

The details, row by row:

- **Inactivity.** "In a public repository, scheduled workflows are automatically disabled when no repository activity has occurred in 60 days." The same page says that "when a public repository is forked, scheduled workflows are disabled by default" ([disabling and enabling a workflow](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/disable-and-enable-workflows)). Re-enable with **Enable workflow** on the workflow's Actions page, `gh workflow enable nightly-report.yml`, or [`PUT /repos/{owner}/{repo}/actions/workflows/{workflow_id}/enable`](https://docs.github.com/en/rest/actions/workflows#enable-a-workflow). A commit that changes the `cron` line, by a user with write access, also reactivates it and makes that user the [actor](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#actor-for-scheduled-workflows).
- **Actor.** GitHub's troubleshooting guide says a scheduled workflow "can stop running even though it is still enabled and manual or `workflow_dispatch` runs still work" when its actor account "is suspended, deleted, or deprovisioned" ([troubleshooting](https://docs.github.com/en/actions/how-tos/troubleshoot-workflows#scheduled-workflow-stopped-running)). `gh workflow list` still shows the workflow as `active`.
- **Actions disabled.** "When you disable GitHub Actions, no workflows run in your repository." An organization or enterprise policy can override the repository setting, and a repository that shows "GitHub Actions is currently disabled for this repository" may be in a GitHub-controlled state that only GitHub Support can review ([repository settings](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository)).
- **Billing.** Public repositories on standard GitHub-hosted runners run free. For private ones, "if your account does not have a valid payment method on file, usage is blocked once you use up your quota" ([Actions billing](https://docs.github.com/en/billing/concepts/product-billing/github-actions#using-more-than-your-included-quota)), and a budget with **Stop usage when budget limit is reached** blocks further use once it's spent ([budgets](https://docs.github.com/en/billing/how-tos/set-up-budgets)). GitHub can email owners and billing managers when included Actions minutes reach 90% and 100% ([included usage alerts](https://docs.github.com/en/billing/concepts/budgets-and-alerts#included-usage-alerts)).

To see each workflow's state at once, use [`gh workflow list`](https://cli.github.com/manual/gh_workflow_list); `--all` includes disabled workflows:

```bash
gh workflow list --all --json name,path,state
```

The REST API's `state` values are `active`, `deleted`, `disabled_fork`, `disabled_inactivity`, and `disabled_manually` ([list repository workflows](https://docs.github.com/en/rest/actions/workflows#list-repository-workflows)).

## Overlaps, Timeouts, and Self-Hosted Runners

**Runs overlap unless you group them.** "The default behavior of GitHub Actions is to allow multiple jobs or workflow runs to run concurrently" ([`concurrency`](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency)). With a `concurrency` group, as in the workflow further down, a new run waits as `pending` while one is in progress, and "any existing `pending` job or workflow in the same concurrency group will be canceled and the new queued job or workflow will take its place." Leave `cancel-in-progress` off for scheduled work: set to `true`, it cancels the run in progress, so a slow nightly job is killed by the next one instead of finishing. `queue: max` lets up to 100 runs wait instead of replacing each other.

**Set a timeout below the default.** A job's `timeout-minutes` defaults to 360 ([`timeout-minutes`](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idtimeout-minutes)), and GitHub-hosted runners allow 6 hours of execution per job ([limits](https://docs.github.com/en/actions/reference/limits)), so a hung step can hold a runner for six hours. Set the timeout a little above the longest normal run. When a job times out, GitHub cancels it, and a step with `if: always()` still runs, since `always()` is for tasks "you expect to run even when a job is canceled" ([`always`](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#always)); the monitoring step below relies on that. [Reviewing AI-generated cron jobs](https://cronradar.com/blog/reviewing-ai-generated-cron-jobs) covers these settings alongside the shell defaults that hide failures. For a scheduled workflow that runs Claude Code through `anthropics/claude-code-action`, [scheduled coding agent runs](https://cronradar.com/blog/scheduled-coding-agent-runs) shows how to cap turns and spend and report the agent's own result.

**Self-hosted runners add a queue.** On a self-hosted runner, "a job can be in the queue for 24 hours before it is automatically cancelled" ([limits](https://docs.github.com/en/actions/reference/limits)). If the runner is offline or busy, the scheduled job starts late once a runner is free, or is canceled without running a step.

## Test the Schedule With `workflow_dispatch`

With `workflow_dispatch` in `on:`, run the workflow now instead of waiting for 03:17, and follow it:

```bash
gh workflow run nightly-report.yml
gh run watch
```

The workflow must be on the default branch to be dispatched, and `--ref` runs another branch's copy ([manually running a workflow](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow)). A dispatched run proves the job works, not the schedule: its `github.event_name` is `workflow_dispatch`, it has no `github.event.schedule`, and the cron line is never used. After the first scheduled time passes, confirm a run appears in `gh run list --event schedule`.

## Get Alerted When a Scheduled Run Doesn't Happen

GitHub's notifications are about finished runs. With Actions notifications enabled, you get one "when any workflow runs that you've triggered have completed", or only for failures ([notifications for workflow runs](https://docs.github.com/en/actions/concepts/workflows-and-actions/notifications-for-workflow-runs)). A scheduled workflow notifies the user who created it, the last user to change its cron syntax, or whoever last re-enabled it, possibly someone who has left the team. A dropped run or a disabled schedule produces no run, so nothing is sent.

That gap is what a [dead man's switch](https://cronradar.com/blog/cron-dead-mans-switch) covers: the workflow checks in, and an outside monitor alerts when a check-in doesn't arrive on schedule. There's no CronRadar SDK for GitHub Actions and no auto-discovery of workflows, so each scheduled workflow reports with its own HTTP check-ins. Store the CronRadar application's API key as a repository secret; [`gh secret set CRONRADAR_API_KEY`](https://cli.github.com/manual/gh_secret_set) prompts for it. Then wrap the job:

```yaml
# .github/workflows/nightly-report.yml
name: Nightly report

on:
  schedule:
    - cron: '17 3 * * *'
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}

jobs:
  report:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    defaults:
      run:
        shell: bash # bash -eo pipefail: a failed pipeline fails the step
    env:
      CRONRADAR_API_KEY: ${{ secrets.CRONRADAR_API_KEY }}
      PING_URL: https://cron.life/api/v1/ping/nightly-report
      MONITOR_SCHEDULE: '17 3 * * *' # keep in step with on.schedule
      GRACE_SECONDS: '3600'
    steps:
      - name: Report start to CronRadar
        timeout-minutes: 1
        run: |
          curl --fail --silent --show-error --max-time 5 --output /dev/null \
            --request POST --get --user "$CRONRADAR_API_KEY:" \
            --data-urlencode "schedule=$MONITOR_SCHEDULE" \
            --data-urlencode "gracePeriod=$GRACE_SECONDS" \
            --data-urlencode "run_id=$GITHUB_RUN_ID" \
            --data-urlencode "attempt=$((GITHUB_RUN_ATTEMPT - 1))" \
            "$PING_URL/start" || true

      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Build and send the report
        run: ./scripts/nightly-report.sh

      - name: Report result to CronRadar
        if: always()
        timeout-minutes: 1
        env:
          JOB_STATUS: ${{ job.status }}
        run: |
          if [ "$JOB_STATUS" = success ]; then
            set -- "$PING_URL/complete"
          else
            set -- --data-urlencode \
              "message=Job $JOB_STATUS: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
              "$PING_URL/fail"
          fi
          curl --fail --silent --show-error --max-time 5 --output /dev/null \
            --request POST --get --user "$CRONRADAR_API_KEY:" \
            --data-urlencode "schedule=$MONITOR_SCHEDULE" \
            --data-urlencode "gracePeriod=$GRACE_SECONDS" \
            --data-urlencode "run_id=$GITHUB_RUN_ID" \
            --data-urlencode "attempt=$((GITHUB_RUN_ATTEMPT - 1))" \
            "$@" || true
```

- **Start comes first,** so CronRadar sees when a runner actually began the job, after GitHub's trigger delay and any wait for a runner.
- **The result step always runs.** `if: always()` runs it after a failed step and during a cancellation, including a `timeout-minutes` one, and `job.status` is `success`, `failure`, or `cancelled` ([job context](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#job-context)). Anything but `success` reports `fail`, which alerts immediately. GitHub [warns](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#always) that an `always()` step prone to critical failure can leave the workflow hanging until it times out; `--max-time 5` and a one-minute step timeout keep this one short.
- **Monitoring can't fail the job.** Each request gives up after 5 seconds, and `|| true` ignores its errors, so a CronRadar outage doesn't change the job's result. On a self-hosted runner, make sure curl is installed.
- **One run, one ID.** `GITHUB_RUN_ID` is `github.run_id`, which "does not change if you re-run the workflow run." `GITHUB_RUN_ATTEMPT` starts at 1 ([variables](https://docs.github.com/en/actions/reference/workflows-and-actions/variables#default-environment-variables)), and CronRadar's `attempt` is zero-based, so the first re-run reports `attempt=1`.
- **Self-registering.** Every check-in carries the schedule, so the first run creates the monitor and a deleted one returns on the next run. `schedule` and `gracePeriod` apply only when a check-in creates the monitor; change an existing monitor's values in its settings.
- **Every failure has a signal.** A dropped run or a disabled workflow sends nothing, and a run delayed past the grace period sends nothing in time; either way, the monitor goes `critical` as missed. That's the point: the alert doesn't depend on GitHub creating a run. A runner lost mid-job leaves `start` as the last event, and the monitor goes `critical` as stuck once the next scheduled time plus the grace period passes.

Alerts go to email, Slack, a webhook, Discord, or PagerDuty. Manual runs check in too, which makes `gh workflow run` a test of the whole path, and CronRadar counts a manual run like a scheduled one. To report scheduled runs only, add `github.event_name == 'schedule'` to both report steps' `if` conditions.

### Choosing the Grace Period

With `start` as the first step, the grace period covers only start delay, not the job's run time ([grace periods](https://cronradar.com/blog/cron-grace-period) explains why). GitHub documents no bound on scheduled-run delays, so no value is guaranteed to cover them. Choose what you'd tolerate, then check it against the `gh run list` output above:

- **Daily or less often: 3,600 seconds.** For a nightly report, hearing at 04:17 instead of 03:30 rarely changes what anyone does, and an hour leaves room for a badly delayed run. Raise it if your measured delays come close.
- **Every few minutes: less than the interval.** Each check-in moves the expected time to the next scheduled run, so with a 15-minute schedule and a 60-minute grace, one dropped run is never flagged: the next run checks in first. A grace of 600 seconds flags each missing run, at the cost of an alert whenever GitHub delays a run past 10 minutes. A grace longer than the interval flags only a sustained gap.

### Workflows With `timezone` or Several Schedules

CronRadar evaluates every schedule in UTC ([schedule syntax](https://docs.cronradar.com/schedules)). Without `timezone`, send the cron string unchanged. With one, convert it. GitHub's example, `30 5 * * 1-5` in `America/New_York`, runs at 09:30 UTC in summer and 10:30 UTC in winter. CronRadar's rule for zones with daylight saving is the earlier UTC time and a grace period of at least an hour plus the job's usual duration, so send `30 9 * * 1-5` and add the hour to your delay allowance: 7,200 seconds instead of 3,600. If the conversion crosses midnight, shift the day fields too. Don't restrict both day fields in one entry: POSIX cron, [which GitHub cites](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/crontab.html), runs on a date matching either, while CronRadar expects both. Leaving `timezone` out avoids all of this.

When one expression can't cover all the entries, give each entry its own monitor. Without `timezone`, `github.event.schedule` is already the UTC expression CronRadar needs:

```yaml
env:
  MONITOR_SCHEDULE: ${{ github.event.schedule || '17 3 * * 1-5' }}
  PING_URL: https://cron.life/api/v1/ping/${{ case(github.event.schedule == '47 11 * * 6', 'sync-saturday', 'sync-weekdays') }}
```

[`case()`](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#case) returns the value for the first true condition, or the last argument. A manual run has no `github.event.schedule`, so it reports to the weekday monitor.

## Frequently Asked Questions

### Why is my GitHub Actions scheduled workflow not running?

Check that the workflow file, with its current schedule, is on the default branch. Then run `gh workflow list --all --json name,path,state`: `disabled_inactivity` means a public repository went 60 days without activity, `disabled_fork` means a fork, and `disabled_manually` means someone turned it off. If the state is `active` and manual runs work, the workflow's actor account may be suspended or deleted, and a commit changing the cron restarts it. Also check whether Actions is disabled for the repository or organization, and whether a private repository has run out of minutes.

### Why does my GitHub Actions cron run late?

GitHub delays scheduled runs when Actions is under high load, which includes the start of every hour, and it documents no maximum delay. Move the schedule off minute 0 and measure your delays with `gh run list --event schedule`. Jobs on self-hosted runners can also wait in the queue for up to 24 hours.

### What time zone does GitHub Actions cron use?

UTC, unless the schedule entry has a `timezone` key with an IANA name such as `Europe/Berlin`. In a zone with daylight saving, a time skipped in spring runs at the next valid time. GitHub doesn't document what happens in the repeated hour in autumn.

### Why was my scheduled workflow disabled?

In a public repository, GitHub disables scheduled workflows automatically after 60 days without repository activity, and in a fork of a public repository they start disabled. Re-enable the workflow on the Actions tab, with `gh workflow enable`, or through the REST API. A commit that changes the cron, by someone with write access, also reactivates it.

### How do I get alerted when a GitHub Actions schedule doesn't run?

GitHub notifies about runs that complete, so a dropped run or a disabled schedule sends nothing. Have the workflow check in to an external monitor at its start and end, and alert when a check-in is missing past a grace period. With CronRadar, a missing check-in turns the monitor `critical` and opens an incident, and a `fail` check-in alerts at once.
