Cron jobs run silently. That's the feature. No window, no output, no status indicator. A job that ran successfully at 3am looks exactly like a job that never ran at 3am — unless you instrumented it to say otherwise.
Heartbeat monitoring is the most lightweight way to close that gap. The idea is simple: your job sends an HTTP ping to a monitoring endpoint at the end of each successful run. If the monitoring service doesn't receive a ping within the expected window, it fires an alert. No ping means something went wrong — the job failed, was killed, or never ran at all.
A heartbeat monitor works like a dead man's switch. The monitoring service expects to hear from your job on a predictable schedule. As long as it keeps hearing the ping, everything is fine. The moment the ping goes missing, the alarm fires.
This is fundamentally different from uptime monitoring, which checks whether a URL responds. Heartbeat monitoring checks whether a process completed successfully. It catches failures that uptime probes can't see: a cron job that throws an exception and exits, a job that gets stuck in an infinite loop and never finishes, or a scheduler that stops dispatching jobs entirely.
Implementation is a single line appended to your cron command:
# crontab — ping fires only if the job exits with code 0
0 3 * * * /opt/jobs/backup.sh && curl -fsS --retry 3 https://cron.watch/ping/YOUR-JOB-ID
The && operator ensures the ping only fires on success. If the script exits with a non-zero code, the curl never runs, the monitor doesn't receive the ping, and you get alerted.
Most monitoring focuses on active signals: an HTTP endpoint returns 200, a service responds to a health check. Heartbeat monitoring inverts this — you're monitoring for the absence of a signal. That inversion lets it catch three failure modes that are otherwise invisible:
Your script runs, encounters an error, and exits with a non-zero code. On Linux crontab, that error goes to the local mail spool, which nobody reads. The job is "running" in the sense that cron dispatched it — but it's not doing anything useful. Without a heartbeat, this can go undetected for days.
The scheduler process itself crashes, gets OOM-killed, or fails to restart after a deployment. All jobs that should have run during that window simply don't run. There's no error to catch because nothing happened. Heartbeat monitoring catches this because the expected pings stop arriving.
A data processing job that used to finish in 4 minutes now takes 3 hours because the dataset grew. The server kills it after a timeout. The job doesn't reach the ping at the end, so the monitor fires — even though cron technically "ran" the job. This is the failure mode most teams don't discover until it causes a real outage.
# Append the ping with &&
0 2 * * * /usr/bin/python3 /opt/jobs/nightly_sync.py && curl -fsS https://cron.watch/ping/SYNC-JOB
# For jobs that must complete within a time window, use a timeout wrapper
0 2 * * * timeout 3600 /opt/jobs/long_job.sh && curl -fsS https://cron.watch/ping/LONG-JOB
on:
schedule:
- cron: '0 2 * * *'
jobs:
nightly-sync:
runs-on: ubuntu-latest
steps:
- name: Run sync
run: python nightly_sync.py
- name: Heartbeat ping
if: success()
run: curl -fsS https://cron.watch/ping/SYNC-JOB
Using if: success() ensures the ping only fires when all prior steps succeeded. If any step fails, the ping is skipped and the monitor alerts you.
import urllib.request
def handler(event, context):
# your job logic here
process_records()
# ping only after successful completion
urllib.request.urlopen('https://cron.watch/ping/LAMBDA-JOB')
return {'status': 'ok'}
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-sync
spec:
schedule: "0 2 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: sync
image: myapp:latest
command: ["/bin/sh", "-c", "python sync.py && curl -fsS https://cron.watch/ping/SYNC-JOB"]
When configuring a heartbeat monitor, you set a grace period: how long after the expected ping time should the monitor wait before firing an alert. Too short and you get false positives from jobs that occasionally run a bit slow. Too long and you find out about a failure hours after it happened.
A practical starting point: set the grace period to 10–20% of the job's normal runtime, with a floor of 5 minutes. For a job that normally completes in 10 minutes, a 15-minute grace period is reasonable. For a job that runs in 30 seconds, use 5 minutes.
For jobs with multiple stages, you can send intermediate pings to distinguish which stage failed. This requires a monitoring service that supports named check-ins or sequential steps, but the pattern is the same:
#!/bin/bash
set -e
# Stage 1: fetch data
fetch_data.py && curl -fsS "https://cron.watch/ping/DAILY-JOB?msg=fetch_complete"
# Stage 2: process records
process.py && curl -fsS "https://cron.watch/ping/DAILY-JOB?msg=process_complete"
# Stage 3: final sync — completion ping
sync.py && curl -fsS "https://cron.watch/ping/DAILY-JOB"
If stage 2 fails, only the stage 1 ping arrived. You know immediately where the failure occurred without parsing log files.
Not every cron job needs a heartbeat monitor. Prioritize based on impact:
If you're unsure, start with your top 3–5 most critical scheduled jobs. Adding a single && and a curl call costs 2 minutes. The first time it catches a failure before a user does, you'll instrument everything else.
CronWatch monitors your scheduled jobs across every platform — crontab, GitHub Actions, Heroku Scheduler, AWS EventBridge, Kubernetes CronJob. Get alerted in Slack or email within minutes of a missed window or failed run.
Join the CronWatch waitlist → Parse a cron expression