Why Cron Jobs Fail Silently (and How Heartbeat Monitoring Catches It)

Why Cron Jobs Fail Silently (and How Heartbeat Monitoring Catches It)

Every team has this story. Something breaks, you go to restore from last night’s backup, and discover the backup job stopped running three weeks ago. No error, no alert, no email. It just quietly stopped.

Cron is wonderfully simple, and that simplicity is the problem: it runs your command and moves on. It doesn’t care whether the command succeeded, and it certainly won’t tell you when a job never ran at all. This article covers the most common reasons cron jobs fail silently and the simple pattern that catches all of them: heartbeat monitoring.

Nine ways cron jobs fail without telling you

1. Cron’s environment isn’t your shell

Cron runs jobs with a minimal environment, usually a PATH of just /usr/bin:/bin and none of your shell profile. A script that works perfectly when you run it by hand fails under cron because it can’t find node, php, aws or a virtualenv. Use absolute paths, or set PATH at the top of the crontab.

2. Output goes nowhere

By default, cron emails any output to the local user. On most modern servers there’s no mail system configured, so error messages vanish. Redirect output to a log file you actually read:

0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

3. The percent sign trap

In a crontab, % means “new line”. A command like tar czf backup-$(date +%F).tgz gets cut off at the % and fails. Escape it as \%, or move the logic into a script.

4. Scripts that fail but exit 0

Shell scripts carry on after errors by default, and a pipeline reports only the last command’s exit code. A backup script can fail to dump the database, compress an empty file and exit successfully. Start scripts with set -euo pipefail.

5. Timezones and daylight saving

Servers usually run in UTC, so “run at 2 a.m.” may not mean your 2 a.m. On servers set to a local timezone, jobs scheduled during the daylight-saving changeover can run twice or not at all.

6. Overlapping runs

A job scheduled every 5 minutes that sometimes takes 7 piles up copies of itself, competing for locks and resources until something falls over. Wrap it with flock so a new run skips if the previous one is still going:

*/5 * * * * flock -n /tmp/sync.lock /usr/local/bin/sync.sh

7. The crontab disappears

The server is rebuilt, the app moves to a container, a deploy script overwrites the crontab, or the cron daemon simply isn’t running on the new machine. The job doesn’t fail. It just no longer exists, which no log will ever record.

8. Things the job depends on change

An API token expires, a database password is rotated, a bucket policy changes, or the disk fills up. The job runs exactly as before and fails at the first step.

9. The job runs but does nothing

The export completes in two seconds and writes zero rows. Technically successful, practically useless. Build sanity checks into important jobs: minimum file size, minimum row count.

Why normal monitoring misses all of this

Uptime monitoring works by asking a service a question and checking the answer. A cron job doesn’t answer anything. It runs, it exits. The failure you need to detect isn’t an error, it’s an absence: the thing that should have happened didn’t.

That calls for a different pattern, sometimes called a dead man’s switch.

How heartbeat monitoring works

  1. The monitoring service gives each job a unique URL and an expected schedule, such as “once a day” or “every 5 minutes”.
  2. At the end of every successful run, the job sends a request to its URL. That’s the heartbeat.
  3. If a heartbeat doesn’t arrive within the expected period plus a grace period, you get an alert.

Because the alert fires on silence, it catches every failure above: the job crashed, the script errored, the crontab vanished, the server was off. It doesn’t matter why the heartbeat stopped. You find out that it did.

Adding a heartbeat to a cron job

The simplest version is one line:

0 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 –retry 3 https://monitor.example/hb/your-id > /dev/null

  • && means the ping is only sent if the backup script exits successfully.
  • -fsS makes curl fail on HTTP errors and stay quiet unless something goes wrong.
  • -m 10 stops a slow network from hanging the job.
  • –retry 3 rides out a brief network blip.

The same idea works everywhere jobs run:

  • Inside a script: add the request as the last line, after the work and your sanity checks.
  • Laravel: the scheduler’s pingOnSuccess() method sends the heartbeat for you.
  • systemd timers: add an ExecStartPost line or ping from the end of the script.
  • Kubernetes CronJobs, GitHub Actions, Windows Task Scheduler: ping as the final step.
  • Long-running workers: ping every minute from the worker loop, so a hung or crashed worker is caught too.

Choosing the period and grace time

JobExpected everySensible grace
Queue worker1 minute1–2 minutes
Data sync15 minutes5–10 minutes
Hourly report1 hour15 minutes
Nightly backup1 day1–2 hours
Weekly cleanup1 week6 hours

Grace should cover normal variation in run time. Too short and you get false alarms. Too long and you find out late.

What to put heartbeats on first

  1. Backups. The job you only check when you desperately need it.
  2. Billing and invoicing runs. Silent failures here cost real money.
  3. Certificate renewals and other renewals.
  4. Data syncs and imports that other systems rely on.
  5. Queue workers and scheduled email sends.
  6. Cleanup jobs, whose failure shows up weeks later as a full disk.

Putting it together

Harden your jobs (absolute paths, set -euo pipefail, logging, flock), then add cron job monitoring with heartbeats so you hear about the failures hardening can’t prevent. FastPing, for example, gives each job a heartbeat URL and alerts by email, Slack, Discord or Telegram when a ping is missing past its grace period, alongside the uptime checks for the rest of your stack.

The next time a job stops, you’ll know the same day, not three weeks later when you need that backup.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *