Guide · Monitoring

Cron Job Heartbeat Monitoring to Catch Silent Failures Early

Learn how heartbeat monitoring catches silent cron failures, with copy-ready examples for crontab, systemd, GitHub Actions, Docker, WP-Cron, Laravel and more.

By SiteDownCheck Editorial · Updated October 4, 2026 · 9 min read

Backups, invoice runs, cache warmers and cleanup tasks share a nasty property: when they stop working, nothing visibly breaks until the day you need the result. This guide explains why scheduled jobs fail silently, how the heartbeat (dead-man's-switch) pattern solves it, and gives you working snippets for the schedulers people actually use, along with the timezone and long-running-job traps that cause most false alarms.

Why cron fails silently

A website that is down is noticed because requests fail. A job that does not run produces no request, no error and no log line. Common causes:

  • The job never started. The server was rebuilt, the crontab was lost, a deploy overwrote the user's cron table, or the scheduler service was stopped.
  • It started and crashed. Output goes to local mail that nobody reads, or to /dev/null.
  • It started and hung. A lock file or a stuck network call means it never finishes, and later runs refuse to start.
  • It ran with the wrong environment. Cron uses a minimal PATH and none of your shell profile, so a command that works interactively fails under cron.
  • It "succeeded" while doing nothing. An empty input directory or an expired API token can yield exit code 0 with no useful output.
  • The machine was off. Laptops, spot instances and containers that are not running cannot run jobs.

Monitoring the server does not help, because the server is fine. You need to monitor the outcome.

The heartbeat pattern

Instead of a monitor asking your server "are you up?", the job tells the monitor "I just finished." The monitor expects that message on a schedule, and raises an alert when it does not arrive. That inversion is why it is also called a dead-man's switch: silence is the signal.

A heartbeat monitor has two settings:

  • Expected interval: how often a successful run should report. A nightly backup: every 24 hours.
  • Grace period: how late a report may be before it counts as missed. This absorbs normal variation in run time and start time.

A heartbeat missing for longer than interval plus grace triggers the alert. On SiteDownCheck, the cron monitoring feature works exactly this way: each monitor gets a unique ping URL, your job requests it after succeeding, and a missed ping alerts you. There is also a /fail variant of the URL, so a job can report an explicit failure immediately rather than waiting for the grace period to expire.

Success pings and fail pings

  • Success ping: requested only when the job completed correctly. If the job crashes, the ping is never sent, and the missed heartbeat fires an alert after the grace period.
  • Fail ping: requested by your error handler when the job knows it failed. This gives you an alert in minutes instead of after the full interval and grace.

Use both. The fail ping is faster; the missing success ping is the safety net for cases where the job could not even run its own error handling (a killed process, a missing server, a broken scheduler).

In the examples below, replace PING_URL with the exact URL shown for your monitor in the dashboard, and use its fail URL (the ping URL with /fail) where shown.

Examples by scheduler

Classic crontab

The shell operator && runs the next command only if the previous one exited with status 0, which is exactly the success condition.

# m   h  dom mon dow  command
30 2 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 3 -o /dev/null PING_URL

Flags: -f makes curl fail on HTTP errors, -sS silences progress but shows errors, -m 10 caps the total time so a network hiccup cannot hang your job, and --retry 3 retries transient failures.

To send a fail ping as well:

30 2 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 3 -o /dev/null PING_URL || curl -fsS -m 10 -o /dev/null PING_URL/fail

Be careful with A && B || C: if the success ping (B) itself fails, C runs too. For anything important, wrap it in a small script.

#!/usr/bin/env bash
set -uo pipefail
PING_URL="https://example.invalid/your-unique-ping-url"

if /usr/local/bin/backup.sh; then
  curl -fsS -m 10 --retry 3 -o /dev/null "$PING_URL"
else
  curl -fsS -m 10 --retry 3 -o /dev/null "$PING_URL/fail"
  exit 1
fi

Also set PATH and MAILTO at the top of the crontab, and use full paths, so that cron's minimal environment does not break your job.

systemd timers

For a oneshot service, ExecStartPost runs only after ExecStart finishes successfully. An OnFailure unit handles the fail ping.

# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup
OnFailure=backup-fail-ping.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
ExecStartPost=/usr/bin/curl -fsS -m 10 --retry 3 -o /dev/null PING_URL
# /etc/systemd/system/backup-fail-ping.service
[Service]
Type=oneshot
ExecStart=/usr/bin/curl -fsS -m 10 --retry 3 -o /dev/null PING_URL/fail
# /etc/systemd/system/backup.timer
[Timer]
OnCalendar=*-*-* 02:30:00 UTC
Persistent=true
RandomizedDelaySec=60

[Install]
WantedBy=timers.target

Persistent=true runs a missed job after the machine boots, which suits laptops and rebooted servers.

GitHub Actions scheduled workflows

Scheduled workflows use cron syntax in UTC. Add a final step that runs only if earlier steps succeed, and another with if: failure().

name: nightly-sync
on:
  schedule:
    - cron: "30 2 * * *"
  workflow_dispatch:

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/sync.sh
      - name: Report success
        run: curl -fsS -m 10 --retry 3 -o /dev/null "${{ secrets.PING_URL }}"
      - name: Report failure
        if: failure()
        run: curl -fsS -m 10 --retry 3 -o /dev/null "${{ secrets.PING_URL }}/fail"

Two behaviours matter here: scheduled runs can start late when GitHub is busy, so give your grace period generous room, and in public repositories scheduled workflows may be disabled automatically after a long period without repository activity. A heartbeat monitor is the only reliable way to notice that.

Docker

A container that runs a job and exits can ping from the same command:

docker run --rm myorg/report-job:latest sh -c \
  'python /app/report.py && curl -fsS -m 10 -o /dev/null "$PING_URL"'

Pass PING_URL as an environment variable, and make sure the image includes curl (or use Python's standard library as shown below). If the scheduler runs on the host, put the whole docker run line in the host's crontab with && as before. For long-lived containers that run an internal scheduler, ping from inside the job code, and remember that if the container itself dies, the missing ping is what alerts you.

WordPress and WP-Cron

WP-Cron is not a real scheduler. It runs when someone visits the site, so on a quiet site, scheduled posts, backups and email queues can slip by hours. On a busy site, it adds load to page requests. Also, if the site is down, WP-Cron does not run at all.

The standard fix is to disable the visit-triggered behaviour and call it from system cron:

# wp-config.php: define('DISABLE_WP_CRON', true);

*/5 * * * * cd /var/www/html && wp cron event run --due-now --quiet && curl -fsS -m 10 -o /dev/null PING_URL

Now the heartbeat reports that the scheduler is actually running every 5 minutes. If you cannot use WP-CLI, request wp-cron.php over HTTP from cron instead, using curl -fsS https://example.com/wp-cron.php?doing_wp_cron, and note that this only proves the page responded, not that every event succeeded.

Laravel scheduler

Laravel needs one cron entry that runs every minute:

* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1

Then ping from individual tasks:

$schedule->command('reports:send')
    ->dailyAt('02:30')
    ->pingOnSuccess('PING_URL')
    ->pingOnFailure('PING_URL/fail');

Also monitor the scheduler itself with a heartbeat on the every-minute entry, because if that single cron line disappears, every scheduled task silently stops.

Python and Node scripts

When the script itself can report, put the ping at the very end of the success path.

import urllib.request, sys

PING_URL = "PING_URL"

def ping(suffix=""):
    try:
        urllib.request.urlopen(PING_URL + suffix, timeout=10).read()
    except Exception as e:
        print("heartbeat failed:", e, file=sys.stderr)

try:
    run_job()
    ping()
except Exception:
    ping("/fail")
    raise
const PING_URL = process.env.PING_URL;

async function ping(suffix = "") {
  try {
    await fetch(PING_URL + suffix, { signal: AbortSignal.timeout(10000) });
  } catch (err) {
    console.error("heartbeat failed:", err.message);
  }
}

try {
  await runJob();
  await ping();
} catch (err) {
  await ping("/fail");
  throw err;
}

Note that a failing ping should not crash the job: a monitoring hiccup must not corrupt your real work.

Long-running jobs

A job that takes 40 minutes cannot report in 5. Choose among these:

  1. Set the grace period to exceed the worst-case run time. If the job normally takes 20 to 40 minutes, a grace of 60 minutes on a daily job is reasonable.
  2. Prevent overlap with flock -n /tmp/job.lock command so a slow run does not collide with the next one, and send a fail ping when the lock is held too long.
  3. Split the job into stages and give each its own monitor if different parts fail differently.
  4. Alert on runtime, not just completion, by making the job exit with an error after a timeout (timeout 2h command), which then triggers the fail ping.

Timezone and DST traps

  • Cron uses the server's local time unless told otherwise. If the server moves from UTC to a local zone, the whole schedule shifts.
  • With daylight saving time, a job at 02:30 may not run on the day clocks jump forward (that time does not exist) and may run twice when clocks go back. Schedule important jobs in UTC, or away from the 01:00 to 03:00 local window.
  • GitHub Actions and many container platforms are always UTC. Do not assume local time.
  • A heartbeat with a fixed 24-hour expectation can misfire on a DST day if the job is scheduled in local time. Using UTC avoids this.

Next steps

FAQ

What is a dead-man's switch in monitoring?

It is an alert that fires when something stops happening instead of when something bad happens. The job must keep checking in; if the check-ins stop, the system assumes the job has failed. It is ideal for scheduled work that fails without visible errors.

How long should the grace period be?

Long enough to cover normal variation in start time and run time, short enough that a real miss is noticed while it still matters. For an hourly job that runs in 2 minutes, 10 to 15 minutes is typical. For a nightly job with variable load, an hour is common.

Why not just email the output of the cron job?

Output emails only tell you about jobs that ran and printed something. If the job never starts, nothing is sent, so there is nothing to notice. Heartbeats alert on absence, which is the failure you most need to catch.

Should I ping at the start and end of the job?

For SiteDownCheck's cron monitor, send the success ping after the job completes and use the fail ping for explicit failures. A start-of-job ping would report success before the work is done. Use a grace period that covers the job's run time instead.

Does a ping prove the job did the right thing?

No. It proves the command exited successfully. Make the job fail loudly if its work is incomplete, for example by checking that a backup file exists and has a plausible size before the ping is sent.

Get notified when this guide updates

Plus occasional practical tips on uptime and monitoring, nothing else. Unsubscribe anytime.

This page is general information written by our editorial team and updated when documentation changes. Servers and providers differ, so test changes safely first. See our editorial policy and disclaimer.