GitHub Actions schedule: triggers are convenient. You write a cron expression, commit a workflow file, and the job runs on a cadence. What the UI doesn't surface clearly: when a scheduled workflow stops running — or runs but fails — the default notification behavior is nearly silent. No dashboard alert. No Slack message. Often, just a quiet failure that nobody catches until downstream breakage appears.
This article covers why GitHub Actions cron jobs fail silently, three concrete approaches to monitor them, and how to set up an external heartbeat monitor with a single workflow step.
There are three distinct failure modes, each with different observability characteristics.
GitHub automatically disables scheduled workflows on repositories with no activity for 60 days. "Activity" means commits, pull requests, or other repository events — not workflow runs themselves. A repo that has only a scheduled job (and nothing else happening) is especially vulnerable: the workflow keeps the repo "active" in your mind, but GitHub doesn't count workflow runs as activity. When the freeze triggers, GitHub sends one email notification. If you miss it, the workflow just stops. For the full list of causes, see our post on GitHub Actions scheduled workflows not running.
GitHub's scheduler is best-effort. Under high load, queued scheduled runs can be delayed or dropped entirely. The GitHub documentation acknowledges this. In practice it's rare, but for workflows with downstream dependencies — nightly data processing, billing cycles, time-sensitive cleanup — a dropped run at 2 AM may not surface until the next morning when something is obviously broken.
GitHub does send email on workflow failure — but only to the repository owner, and only if the failure is a hard exit. Steps marked continue-on-error: true swallow failures silently. Business logic that catches exceptions and exits with code 0 looks like success to GitHub. A workflow that completes in 2 seconds when it should take 4 minutes completed, technically, without error.
An external heartbeat monitor inverts the alerting logic. Instead of watching for a failure signal, you watch for the absence of a success signal. Your workflow sends an HTTP ping at the end of each successful run. If the monitor doesn't receive the ping within a defined window, it alerts you.
This is the only approach that catches all three failure modes — job skipped, job failed, job ran too slowly — with a single mechanism. The monitor doesn't care why the ping didn't arrive. It just alerts when the expected heartbeat is missing.
Tools in this space: CronWatch, healthchecks.io, Better Uptime, and Dead Man's Snitch. See our comparison of cron monitoring tools for a detailed breakdown.
Add a step that posts to Slack on failure. This is more visible than email but still only catches explicit failures — not missed runs. It's a good complement to a heartbeat monitor, not a replacement.
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
channel-id: 'C12345678'
slack-message: "Nightly sync failed in ${{ github.repository }}"
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
The limitation: if: failure() only fires when the job actually runs and fails. A skipped or frozen workflow never triggers it.
GitHub provides a workflow status badge URL for every workflow. You can embed this badge in your README or monitor it with an uptime checker. When a workflow fails, the badge changes from passing to failing.

This is a passive approach — useful for at-a-glance status in a README, but it requires someone to look at it. An uptime monitor polling the badge URL can make it active, but you're now running infrastructure to monitor infrastructure.
The heartbeat approach requires two things: a monitoring service that expects periodic pings, and a workflow step that sends the ping on successful completion.
The workflow step is one line:
- name: Heartbeat
if: success()
run: curl -s https://your-monitor-url/heartbeat/TOKEN
Here's a complete example of a monitored nightly workflow:
name: Nightly Data Sync
on:
schedule:
- cron: '0 3 * * *' # 3 AM UTC every night
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run sync
run: python sync.py
- name: Heartbeat
if: success()
run: curl -s https://your-monitor-url/heartbeat/TOKEN
The if: success() condition ensures the heartbeat only fires when all previous steps passed. If python sync.py exits non-zero, the heartbeat step is skipped, the monitor sees a missing ping, and you get alerted.
Most heartbeat monitors let you configure a grace period — how long after an expected ping the monitor waits before alerting. For a daily job, a reasonable setting is 30–60 minutes. This absorbs GitHub's scheduler delay variance without generating false positives.
For a weekly job (0 3 * * 1), set the grace period to 1–2 hours. The longer the schedule interval, the more margin you need for scheduler variance.
Before deploying a monitored workflow, verify the cron expression fires when you expect it to. GitHub Actions uses UTC — a job at 0 9 * * * is 9 AM UTC, which may be 1 AM or 2 AM Pacific depending on daylight saving. Use the cron expression parser to preview the next 10 run times before committing.
Once a heartbeat is in place, there are two additional signals worth capturing.
A job that completes but takes 10× longer than usual is a signal — an API is slow, a dataset grew unexpectedly, or a resource is constrained. GitHub Actions records run duration in the workflow UI, but doesn't alert on anomalous duration. Some external monitors let you set a maximum expected duration alongside the grace period, alerting when a run takes too long even if it eventually succeeds.
A job that succeeds structurally but produces wrong output — 0 rows synced, empty report, empty S3 object — is a silent failure that heartbeat monitoring won't catch. Add an explicit assertion step before the heartbeat ping:
- name: Validate output
run: |
ROW_COUNT=$(python -c "import json; d=json.load(open('output.json')); print(len(d['rows']))")
if [ "$ROW_COUNT" -lt "1" ]; then
echo "Output validation failed: 0 rows"
exit 1
fi
- name: Heartbeat
if: success()
run: curl -s https://your-monitor-url/heartbeat/TOKEN
With this pattern, a zero-row sync fails the job explicitly, skips the heartbeat ping, and triggers the monitor alert — all without any separate alerting infrastructure.
The minimum viable monitoring setup for a GitHub Actions scheduled workflow:
curl -s https://your-monitor-url/heartbeat/TOKEN with if: success().With this setup, you get alerted within an hour whether the workflow was frozen, failed, skipped, or ran but produced bad output. That covers the vast majority of real-world failure modes with less than 20 lines of additional YAML.
CronWatch monitors your scheduled workflows across GitHub Actions, crontab, AWS EventBridge, Kubernetes CronJob, and more. Get alerted in Slack or email within minutes of a missed or failed run. One heartbeat URL. Every platform.
Join the CronWatch waitlist → Parse a cron expression