You get the alert at 9:05: Kindoo Scheduler is down. At 9:07: recovered. You open the job's dashboard and everything looks fine. It ran at 9:04, it ran at 9:08, nothing errored. This happens twice more before lunch, and by then you are considering muting the monitor, which would be exactly the wrong move, because the monitor is right. Something was late. The only question is what.

This guide is the diagnosis we actually ran on that monitor, generalized. A heartbeat alert has one precise meaning: no ping arrived between the previous ping and the deadline, and the deadline is the expected interval plus the grace period. Four different things can produce that silence, and they need four different fixes. The job itself. The scheduler or platform that runs it. The network the ping crosses. Or a monitor whose interval and grace describe a schedule the job no longer keeps. Here is how to tell them apart, in the order that is fastest.

Step 0: read the alert literally

CronAlert's heartbeat alert text is the whole specification: Heartbeat not received within expected 60s interval (+180s grace). That means the last ping arrived more than 240 seconds before the check that fired. Compare that with the incident's duration. An incident that lasted 7 seconds means the ping arrived 247 seconds after the previous one: just over the line. An incident that lasted 4 minutes means a gap of about 8 minutes on a job that should run every minute. Those are different problems, and you already know which one you have before opening anything else. The grace-period guide covers the deadline math in detail.

Step 1: look at the ping rate, not the incidents

Incidents show you where the line was crossed. The ping history shows you how the job has actually been behaving. For a per-minute job, count pings per hour; for a nightly job, list the arrival times for the last two weeks. On the Kindoo monitor, the history looked like this:

10-21 ..  60 60 60 60 60 60 60 60 61 60 59 60   ← every hour, all day
10-22 ..  60 60 60 61 61 59 61 60 60 59 60 61
10-23 00  60
10-23 01  60
10-23 02  57   ← something changed here
10-23 03  56
10-23 04  48
10-23 05  52
10-23 07  48

Two facts fall out immediately. The job had been perfect for days, so the monitor's configuration was not the problem. And the rate dropped at a specific hour, so whatever happened has a start time you can correlate against deploys, vendor incidents, and platform status pages. If instead the rate had always been 57 or 58 an hour with incidents sprinkled evenly, you would be looking at a configuration problem (step 5), not an event.

Within the hour, look at the seconds. A cron-driven job pings at nearly the same second every minute. A job whose pings drift from :05 to :50 and back is a job whose own duration varies, which points at a dependency (step 2).

Step 2: the job ran, but did it reach the ping?

The most common cause of a heartbeat alert on a job that "ran fine" is that the ping comes last, after everything else, and everything else includes a dependency that occasionally hangs. The job is not failing. It is sitting in a network read with no timeout, and the ping line is waiting its turn.

That was Kindoo's case. Each tick made about twenty calls to a vendor's API and pinged the heartbeat afterwards. Normal ticks took 15 to 25 seconds. The platform's per-minute analytics showed two ticks that made 18 calls instead of 20 and then ran for 457 and 478 seconds: hung on the nineteenth call for eight minutes. No error anywhere, because nothing had errored; the request simply never returned. The ping did not happen until the hang released, and by then the heartbeat had been overdue for five minutes.

The fix is the same in every language: give every outbound call a deadline, and stop calling a dependency for a while after it times out so one bad minute costs one timeout, not twenty.

// Node / Workers: a deadline on every vendor call
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });

// Shell: the same idea for curl
curl -fsS --max-time 10 --retry 2 "$VENDOR_URL"

And isolate the units of work so one failure does not abort the run before the ping: wrap each site, tenant, or batch in its own try/catch, log it, move on. The heartbeat then means "the loop ran", and the dependency's failures become their own alert. Third-party dependency monitoring covers monitoring the vendor directly, which tells you about the hang before your job does.

How to recognize this pattern from the monitor alone: ping gaps of irregular length, clustered in a period, often followed by two or three pings in the same minute as the hung run and the queued runs behind it finish together.

Step 3: did the scheduler actually run it?

The second cause is that the job did not run at all for some minutes, and nothing in the job can tell you that, because there was no job. Three variants, from most to least common.

Contention at predictable times. A per-minute job that is late by 60 to 90 seconds at :02 past every hour, and only then, is being delayed by the hourly jobs that start at :00. This was the first round of Kindoo flapping, two weeks before the hang: incidents of 3 to 18 seconds, all at the top of the hour. The honest fix is a grace of two or three intervals, since the job is healthy and the lateness is bounded. Choosing a grace period walks through that decision.

The platform skipped the fires. Cloud cron services are not metronomes. While we were fixing the vendor timeout, the heartbeat kept alerting, and the platform's analytics showed no invocation at all for eight consecutive minutes: not a slow run, no run. The provider's status page had an open incident titled "Delayed, missed, and duplicated Cron Triggers". There is nothing to fix in the job for this; the signature is gaps with no corresponding long run, often with duplicate pings in the same window, and the confirmation is the status page. Check it early. GitHub Actions scheduled workflows, Vercel cron, and Cloudflare Cron Triggers each have their own documented delay behaviors, and the heartbeat is how you find out they are happening to you.

A hung run blocked the next ones. Some schedulers will not start a new run while the previous one is still going. Then a single eight-minute hang becomes eight missing runs, and the pattern looks like skipped fires even though the cause is step 2. The tell is that the long run shows up in your logs or analytics with a wall time that covers the whole gap.

Step 4: did the ping get out?

The ping is an HTTP request like any other, and it fails like any other. A job that completes and then fails to ping is indistinguishable from a job that did not run, unless the ping logs its own failure. Three things go wrong here.

  • No timeout on the ping. A ping that hangs holds the job open and, if the job is a cron with overlap protection, blocks the next run. Always --max-time 10.
  • Egress blocked. A firewall change, a new egress proxy, or a container network policy that the job's main work does not touch but the ping does. The job's log shows "curl: (28)" or "(7)" at the end of every run.
  • Retries that never happened. -f makes curl exit non-zero on an HTTP error; --retry 3 retries transient failures; without them a single dropped connection is a missed ping.
backup.sh && curl -fsS --max-time 10 --retry 3 https://cronalert.com/api/heartbeat/<token> \
  || echo "heartbeat ping failed: $?" >&2

With that line in place, "did the ping get out" is answered by grepping the job log, and this whole step takes a minute. The heartbeat guide covers the exit-code traps around && and pipelines.

Step 5: does the monitor describe the job's real schedule?

Last, because it is the least likely cause on a monitor that has been quiet for weeks, but first if the monitor is new or the job's schedule recently changed. The expected interval is the spacing between runs, not the job's duration. The grace covers the job's normal lateness plus the ping's travel time. A job moved from nightly to weekly, a job whose duration grew past its interval, or a monitor created with the default grace for a job that is routinely two minutes late will all alert on schedule. Fix the monitor, in the same change as the crontab.

What not to do: widen the grace to make step 2 or step 3 go away. A per-minute heartbeat with a 10-minute grace will not flap on an eight-minute stall. It will also not tell you about the eight-minute stall, which is a real outage of whatever the job does. On Kindoo, that is people's door access.

A decision table

What the ping history showsMost likely causeWhere to confirmFix
Steady rate for weeks, then a drop at a specific hourAn event: dependency, deploy, or platformDeploy log, vendor status, platform statusSteps 2 and 3
Short lateness, always at the same minute of the hourScheduler contentionOther jobs' schedulesGrace of 2–3 intervals
Irregular gaps, then a burst of pings in one minuteA dependency hung; runs queued and finished togetherRun durations in logs or analyticsTimeouts, circuit breaker, per-unit isolation
Gaps with no long run in the logs, duplicate pings nearbyPlatform skipped or doubled firesProvider status pageWait it out; keep the alert
Every run logs a ping failureEgress, DNS, or the ping's own timeoutJob log, curl -v from the hostStep 4
Alerts since day one, or since a schedule changeInterval or grace wrongMonitor settings vs. crontabStep 5

What the monitor should mean when you are done

After this exercise the heartbeat has a sharper meaning than "the job is fine". It means the scheduler fired, the run completed within a bounded time, and the ping got out. Dependency failures have their own alert. Platform incidents are things you confirm on a status page in a minute. And the grace is a number you chose from the job's real lateness, not a number you raised until the alerts stopped. That is a monitor you can trust at 9:05 in the morning, which is the only kind worth having.

Frequently asked questions

How do I see the ping history in CronAlert?

The monitor's detail page shows the beat timeline and recent check results; pings log as "up" results with their arrival time, and overdue evaluations log as "down" results with the deadline message. The API's check-results endpoint returns the same rows if you want to count pings per hour in a script.

The job logs show it ran every minute, but pings are missing. Which step?

Step 4 first, since the job ran and the ping did not arrive. If the job log also shows no ping error, check whether the ping line is reached at all: a job that runs but exits early on a branch that skips the ping looks exactly like this.

Should the ping be at the start or the end of the job?

At the end, after the work succeeded, so a job that starts and dies does not report healthy. If the work's duration is the thing you are worried about, give the heartbeat an expected interval equal to the schedule and a grace equal to the longest acceptable run; batch job monitoring covers that trade-off.

Does CronAlert's own check schedule add lateness?

Heartbeats are evaluated every pass, so an overdue heartbeat is caught within about a minute of its deadline. On Pro the heartbeat interval can be as short as 1 minute; the grace is yours to set.

Which plan do I need?

Heartbeat monitors are on Pro ($5/mo, or $4/mo billed annually), with a user-set expected interval and grace period. HTTP monitors on the dependencies your job calls are on every plan.

Trust the gap, find the cause

A heartbeat alert is the most honest signal in monitoring: it says only that nothing arrived. Read the deadline, look at the rate, then ask the four questions in order: did the job reach the ping, did the scheduler run it, did the ping get out, does the monitor match the schedule. Create an account, set up a heartbeat with a grace you chose on purpose, and put --max-time on the ping today. Related reading: reducing false-positive alerts, alert fatigue, testing your alerts, a watchdog for the monitor itself, and choosing between HTTP, TCP, and heartbeat monitors.