Back to blog

Your Health Check Is a Cron Job Too, and It Fails Like One

A */5 health check on GitHub Actions ran every three to six hours and missed a 44-minute outage. Why uptime checks inherit their scheduler's gaps, and how to fix it.

CronGuard TeamCron Job Monitoring Experts
6 min read
The front of a server rack in near darkness under green light, with dozens of Ethernet patch cables plugged into labelled patch panel ports and looping across the frame

A Five-Minute Check That Ran Every Five Hours

On 30 October 2026 the API behind textstack, an open-source project on GitHub, returned 502 for 44 minutes. There was a health check for exactly this case: a GitHub Actions workflow with cron: '*/5 * * * *'. Its last run before the outage was five hours earlier. A person found the outage by looking at a red cross in CI.

The maintainer pulled the real run times for 29 and 30 October: 06:46, 13:44, 19:13, 23:16, 02:37, 08:56. Three to six hours apart. The fix they merged left the cron line alone and put a comment above it that opens with: "Five minutes is what this asks for, not what it gets."

Nobody was watching the watcher. That is the whole story.

Your Monitor Inherits Its Scheduler's Failures

A health check on a schedule is a cron job. Anything that makes cron jobs late or makes them vanish hits the check too, and the check cannot report on itself.

The Shared Platform Problem

GitHub's docs say the schedule event "can be delayed during periods of high loads," that "high load times include the start of every hour," and that "some queued jobs may be dropped." The textstack issue adds the part that matters here: GitHub "does not queue what it drops." A dropped run never shows up late. It just never happens.

Other people saw the same thing this autumn. A community thread opened on 2 October 2026 logged scheduled runs created more than five hours late on 30 September, six hours late on 1 October, and none at all by the morning of 2 October. Manual dispatch worked the whole time. In the author's words: "The delay is in run creation, not hours waiting for a runner."

The Shared Host Problem

So move the check onto the production server as a systemd timer. The cadence is fixed, and now the check lives on the box it is checking. When that box loses power or fills its disk, the check dies with it. A Kubernetes CronJob probing its own cluster has the same flaw one level up.

Detection Time Is Not the Check Interval

The interval in the config is a request. Worst-case detection is the largest real gap between runs, multiplied by however many consecutive failures your alert rule wants, plus the time it takes a page to reach someone.

Consecutive-Failure Rules Multiply the Gap

"Alert after three failed checks" is fine at five minutes: roughly fifteen minutes to page. At three to six hours per run, the same rule can take most of a day. A 44-minute outage never gets close.

Measure the Cadence You Actually Get

On GitHub Actions the run history has the numbers. Filter to scheduled runs so manual reruns do not paper over the gaps:

gh run list --workflow health-check.yml --event schedule \
  --limit 100 --json createdAt --jq '.[].createdAt' | sort

Subtract neighbouring timestamps and look at the biggest gap. Ignore the average. On a host, systemctl list-timers shows the last and next trigger, and the service's journal shows when it really ran.

A dashboard that shows the last result has the same blind spot. Green stays green when no new run arrives, and a rule like "the last two runs failed" only looks at runs that exist.

Check the Body, Not the Status Code

During the textstack outage the front page kept answering 200, because nginx served the static build from disk. Only the API was down. A check on the home page would have passed. The maintainer's follow-up is blunt: a prober has to hit /api/health/ready, and "it has to read the body, not just the status code."

#!/bin/bash
set -euo pipefail

body="$(curl -fsS --max-time 10 https://example.com/api/health/ready)"
grep -q '"status":"ok"' <<<"$body"

curl -f fails on an HTTP error. The grep fails when the endpoint answers 200 and still says it is unhealthy. Either way the script exits non-zero.

Put the Prober in a Different Failure Domain

The textstack comment says it outright: nothing hosted on GitHub can see the site go down "when GitHub is the unreliable part." The options in the issue were an external prober, a systemd timer on the server, or both. Pick both. Each covers the gap the other leaves.

Make the Monitor Check In Too

Run the check from a timer on the server, where the schedule holds, and have it report success somewhere off the server:

# /etc/systemd/system/api-health.timer
[Timer]
OnCalendar=*:0/5
Persistent=true

[Install]
WantedBy=timers.target
#!/bin/bash
set -euo pipefail

body="$(curl -fsS --max-time 10 https://example.com/api/health/ready)"
grep -q '"status":"ok"' <<<"$body"

# Only reached if the API is healthy
curl -fsS --retry 3 https://cronguard.app/api/ping/your-monitor-id

The outside side needs one rule: no ping in six or seven minutes, alert. It fires on an unhealthy API. It also fires when the timer stops or the host goes away, because silence is the signal.

Write Down the Cadence You Observed

Copy this part even if you change nothing else. textstack kept */5, since a dropped run costs nothing and the check takes six seconds. The comment above it now records what was measured, when, and what it cost. The issue puts it well: "a schedule that lies in a file is worse than no schedule."

Frequently asked questions about health checks on a schedule

Why did a five-minute health check miss a 44-minute outage? It ran as a GitHub Actions scheduled workflow, and GitHub delays scheduled runs under load and drops some without queuing them. The textstack check really ran three to six hours apart, and its last run before the outage was five hours earlier.

Is moving the health check onto the production server enough? It fixes the cadence but puts the check on the same host as the service. If that host loses power or fills its disk, the check stops too and reports nothing. Pair the on-host timer with an outside watcher that expects a check-in.

How do I work out my real detection time? Take the largest gap between actual runs, not the configured interval, and multiply it by the number of consecutive failures your alert rule needs. Then add the time a page takes to reach someone. On a check that runs every few hours, a three-failure rule can take most of a day.

Why should a health check read the response body? In the textstack outage the front page kept returning 200 while the API returned 502, so a check on the home page would have passed. Probing a readiness endpoint and checking that the body reports a healthy status catches failures a status code hides.

How do I get alerted when the health check itself stops running? Have the check ping an outside service only after it passes, and alert when no ping arrives within the expected window. That rule fires on an unhealthy service and also when the timer stops or the host disappears.

Further reading


Conclusion: The textstack check was correct and cheap. Its schedule was the problem, and nobody had measured it. Pull the real run times for every check you rely on, and make sure something outside its host notices when it goes quiet.

Sources: GitHub Docs: Events that trigger workflows, textstack pull request 1064, textstack issue 1054, GitHub community discussion 209332, systemd.time(7).

Share

Related posts

Green monospaced text filling a dark computer screen, showing lines of package-installation and unpacking output scrolling in a terminal
MonitoringWhen a Scheduled Job Can Rewrite Its Own Alerts: The Cronicle Bug
A dark monitor photographed at an angle, filled with syntax-highlighted source code and line numbers running down the left edge
ReliabilityWhy Your GitHub Actions Scheduled Workflows Run Hours Late
A laptop screen at an angle in a dark setting showing syntax-highlighted code in a code editor, with a backlit keyboard visible in the lower portion of the frame
ReliabilityCelery Beat: The Scheduler Your Django App Is Running With No Safety Net

Set up your first monitor.
It'll take 30 seconds.