Monitoring cron jobs: the heartbeat pattern in practice
· 6 min read
HTTP monitoring will not tell you that a nightly cron failed. The heartbeat pattern flips the direction: the cron reports to the monitoring service, and if it does not call in on time, you get an alert.
The problem: background jobs without an HTTP endpoint
A typical backend has several paths:
- Web requests (HTTP/HTTPS to the server) - you monitor these with an uptime check.
- Cron jobs (daily backup, monthly billing, hourly sync) - they have no HTTP endpoint, external monitoring cannot watch them.
- Workers (Celery, BullMQ, Sidekiq) processing a queue - also no HTTP.
When a cron fails (a typo in the crontab, a full disk, a missing environment variable, or an import error after a dependency upgrade), nobody alerts you. You find out on Monday morning, when you notice that no invoices went out over the weekend.
The heartbeat pattern: the cron pings the monitoring
The principle is reversed compared to normal monitoring:
- In the monitoring service you create a heartbeat monitor with an expected interval (for example "every 60 minutes").
- You get a unique heartbeat URL:
https://epulz.io/heartbeat/abc123xyz. - In your cron job, at the end of a successful run, you call that URL (HTTP GET or POST).
- If the ping does not arrive within the expected time (plus a grace period), the monitoring alerts you.
A practical example: heartbeat from a cron job
# /etc/crontab
0 3 * * * www-data /usr/local/bin/backup.sh && curl -fsS -m 10 \
https://epulz.io/heartbeat/abc123xyz > /dev/null
The key is the && operator: the heartbeat is sent only when backup.sh finishes with exit code 0. If the script fails, the ping never arrives and within an hour you get an alert.
Tip: For more thorough coverage, add a "start" heartbeat as well:
curl -fsS -m 10 https://epulz.io/heartbeat/backup-start-xyz > /dev/null
/usr/local/bin/backup.sh && \
curl -fsS -m 10 https://epulz.io/heartbeat/backup-done-xyz > /dev/null
The monitoring can then distinguish "started but did not finish" (the script hung) from "never started" (the cron job never ran).
Python: requests + try/except
import os, requests
HEARTBEAT_URL = os.environ["HEARTBEAT_URL"]
def sync_data():
# ... your logic ...
pass
try:
sync_data()
requests.get(HEARTBEAT_URL, timeout=10)
except Exception as e:
# The heartbeat is not sent - the monitoring alerts you
raise
Node.js: async / await
const HEARTBEAT_URL = process.env.HEARTBEAT_URL;
async function nightlyJob() {
await processInvoices();
await fetch(HEARTBEAT_URL, { signal: AbortSignal.timeout(10000) });
}
nightlyJob().catch(err => {
console.error(err);
process.exit(1);
});
Grace period: how much time to allow before alerting
A heartbeat monitor needs some tolerance. A cron occasionally runs longer than usual, the network has latency, and NTP synchronization can be slightly off. The grace period is the time after the expected interval has elapsed during which the monitoring still waits before it fires an alert.
Practical values:
- Hourly cron: interval 60 min + grace 10 min
- Daily backup (20 min on average): interval 1440 min + grace 60 min
- Weekly report: interval 10080 min + grace 360 min (6 h)
Too tight a grace = false positives. Too loose = a delayed alert at the moment the job actually fails.
Where the heartbeat pattern helps the most
- Nightly DB backups
- Synchronization with external APIs (CRM, accounting, payment)
- Report calculations
- Cleanup jobs (purging old sessions, logs, temporary files)
- The healthcheck cycle of long-running workers
- Scheduled emails, newsletters, billing
Conclusion
Background jobs are often more critical than the website itself, yet they remain a monitoring blind spot. The heartbeat pattern takes only five minutes to implement (just add curl at the end of the cron line) and gives you the same assurance as uptime monitoring of the frontend.
Quick reminder: cron syntax
Most heartbeat mistakes are really cron mistakes - the ping never fires because the schedule was wrong. The five fields are:
┌── minute (0-59)
│ ┌── hour (0-23)
│ │ ┌── day of month (1-31)
│ │ │ ┌── month (1-12)
│ │ │ │ ┌── day of week (0-7, both 0 and 7 = Sunday)
│ │ │ │ │
* * * * * command
| Expression | Runs |
|---|---|
*/5 * * * * |
every 5 minutes |
0 * * * * |
at the top of every hour |
0 3 * * * |
every day at 03:00 |
0 3 * * 1 |
every Monday at 03:00 |
30 2 1 * * |
at 02:30 on the first day of the month |
The classic trap: */5 in the hour field does not mean "every 5 hours from now" but hours divisible by five (0, 5, 10, 15, 20). If the monitor's expected interval and the real cron schedule do not match, you get phantom alerts.
The "dead man's switch" principle
The heartbeat pattern is a dead man's switch: the alarm is silence. This reverses the usual failure mode. Normal monitoring can fail silently - if the monitoring server itself goes down, it simply stops sending alerts and you never find out. The heartbeat is the opposite: the trigger for an alert is the absence of an expected signal, so a job that stops running entirely (the whole server is off, the cron daemon is disabled, the machine was decommissioned and forgotten) is exactly the case it catches best.
Beyond cron: other schedulers
The pattern is the same regardless of what triggers the job:
- systemd timers - add curl to the ExecStart chain or to ExecStartPost=. Pair the OnCalendar= timer with a matching heartbeat interval.
- Kubernetes CronJob - the container's last step pings the heartbeat URL. If the pod fails or is never scheduled (bad node selector, image pull failure), the ping is missing and you get an alert.
- Windows Task Scheduler - finish the task with the PowerShell command Invoke-WebRequest -Uri $env:HEARTBEAT_URL -TimeoutSec 10.
- CI / scheduled pipelines - a nightly GitHub Actions or GitLab schedule can ping the heartbeat as its last step to confirm the pipeline both ran and passed.
Common anti-patterns
- Pinging at the start, not the end. A heartbeat sent before the work only proves the job started. Put it after the work, conditional on success (
&&), so that a mid-run crash also holds back the ping. - Ignoring the exit code.
backup.sh; curl ...(semicolon) sends the ping even if the backup failed. Always use&&. - Silently swallowing curl failures.
-fsSmakes curl fail loudly on an HTTP error, so your job's logs show that the heartbeat could not be delivered (a network problem on the machine). - One heartbeat for ten jobs. If five crons share one URL, you do not know which one stopped running. Give every scheduled job its own heartbeat. If you also expose an HTTP endpoint for the same service, pair the heartbeat with a regular uptime check and a port check on the worker's port, so you cover both "the process is running" and "the scheduled run happened".
A concrete scenario: the silent backup failure
The textbook case for a heartbeat looks like this. A nightly pg_dump writes to a mounted volume. It works for months. Then the volume fills up, pg_dump exits with a non-zero code, and because the cron line used a plain semicolon, the heartbeat is sent anyway and the dashboard stays green. Three weeks later the database is corrupted and there is no usable backup, because every "successful" backup since the disk filled up was a zero-byte file.
The heartbeat pattern catches this on the very first night, but only if it is wired up correctly: condition the ping with && so a non-zero exit holds it back, and consider checking the output, not just the exit code. A backup script can exit with code 0 and still produce a truncated file. A more defensive line verifies that the dump is not trivial before signaling success:
/usr/local/bin/backup.sh && \
test "$(stat -c%s /backups/db.sql.gz)" -gt 1000000 && \
curl -fsS -m 10 https://epulz.io/heartbeat/abc123xyz > /dev/null
Now the heartbeat arrives only if the script succeeded and the resulting file is over 1 MB. Anything smaller and the ping is held back, and you get an alert within the grace period instead of three weeks later.
Start monitoring your cron jobs
ePulz.io supports heartbeat checks with a configurable grace period. 7 days free.
Related
Try ePulz.io free - 7 days, no credit card needed.
Create account