Celery Beat has a strange risk profile: it's a single process, it schedules everything, and when it dies nothing else notices. The workers stay healthy and idle. The broker stays reachable and empty. Your error tracker stays quiet, because code that never runs can't throw. The nightly report just… doesn't get sent, and you find out from a customer, days later.
This is the defining property of scheduler failures — the same one we've covered for plain cron, systemd timers, Kubernetes CronJobs, and GitHub Actions schedules: the failure signal is silence, and silence doesn't page anyone unless you've built something that expects the noise. For Celery, that something is a heartbeat, plus one clever canary that verifies the whole scheduling chain.
How Celery Beat fails — a field guide
- Beat dies. OOM-killed, crashed on a corrupt schedule file, or simply not restarted after a deploy. Every periodic task stops at once. There is no error, because Beat isn't failing to schedule — it doesn't exist.
- Beat schedules, nobody consumes. Beat only publishes to the broker; it never checks that anything consumes. Dead workers, a queue-routing typo, or workers listening to the wrong queue leave tasks piling up in the broker while Beat's logs look immaculate. (Deep dive on the consumption side: monitoring background workers and queue monitoring.)
- Two Beats run at once. Nothing in Celery prevents it — a deploy overlap or a scaled-up "worker" container that also runs Beat means every task fires twice. Duplicate emails, double-charged invoices. Prevention is a singleton/lock plus idempotent tasks; detection, as we'll see, falls out of heartbeats for free.
- The task runs and throws. The one case your error tracker does see — which is precisely why teams over-trust it. Error tracking reports the runs that started and failed. It is structurally incapable of reporting the runs that never started.
- The schedule itself drifts. With the database scheduler (django-celery-beat), a task can be disabled with one click in the admin — by design, silently. Crontab entries also interact with
CELERY_TIMEZONEand DST in ways that can shift or skip a run. A schedule that's wrong looks identical to a schedule that's working, from the inside.
Five different failure modes, one shared observable: the task's side effects stop happening on schedule. So that's what you monitor.
The heartbeat pattern: ping at the end, success only
Create a heartbeat monitor in CronAlert for each periodic task that matters, set the expected interval to the task's schedule, and make the final statement of the task a ping:
import requests
from celery import shared_task
@shared_task
def send_daily_digest():
digest = build_digest()
deliver(digest) # the real work
# Success signal — only reached if everything above completed
requests.get(
"https://cronalert.com/api/heartbeat/YOUR_TOKEN",
timeout=10,
) Placement rules are the same as every scheduled-work heartbeat: last, and only on the success path. Never in a finally block, never before the work commits. If deliver() throws, the ping never runs; if Beat never scheduled the task, the ping never runs; if no worker consumed it, the ping never runs. CronAlert alerts once the expected interval (plus a grace window) passes without a ping — every failure mode in the field guide collapses into the same alert.
If you have many periodic tasks, keep it DRY with a decorator:
import functools
import requests
def heartbeat(token):
def wrap(fn):
@functools.wraps(fn)
def inner(*args, **kwargs):
result = fn(*args, **kwargs) # raises → no ping
requests.get(
f"https://cronalert.com/api/heartbeat/{token}",
timeout=10,
)
return result
return inner
return wrap
@shared_task
@heartbeat("YOUR_TOKEN")
def send_daily_digest():
... One ordering note: put @heartbeat inside @shared_task (listed below it), so the ping happens within the task's execution and a retried task pings only when the retry finally succeeds.
The beat-liveness canary: one task that proves the chain
Per-task heartbeats tell you a daily task failed — tomorrow. For a scheduler that runs your whole background plane, you want to know Beat is dead in minutes. The cheapest way is a canary: a trivial periodic task on a tight schedule whose only job is to ping a heartbeat.
# celery.py
app.conf.beat_schedule = {
"beat-canary": {
"task": "myapp.tasks.beat_canary",
"schedule": 300.0, # every 5 minutes
},
}
# tasks.py
@shared_task
def beat_canary():
requests.get(
"https://cronalert.com/api/heartbeat/CANARY_TOKEN",
timeout=10,
) Set the monitor's expected interval to 5 minutes. Now a single missing ping means one of: Beat is dead, the broker is unreachable, or no worker is consuming — the entire scheduling chain, verified end to end, every five minutes. This is the same trick as the canary CronJob in Kubernetes, and it's the first monitor you should add: it covers the shared infrastructure that every other periodic task depends on.
Route the canary through the same broker and queue as your real tasks — a canary on a special dedicated queue verifies a chain your real work doesn't use.
What heartbeats catch that logs and error trackers can't
- The disabled task. Someone unticked it in the Django admin three weeks ago. No error, no log line — but no ping either.
- The duplicate Beat. A monitor expecting one ping per day that receives two, seconds apart, is a cheap tell that two schedulers are live. Treat unexpected extra pings as a smell worth investigating even though they won't fire an alert by themselves.
- The timezone shift. A DST transition that moves your 2:30 AM task doesn't error — it just moves the ping. The gap (or the surprise timing in the monitor's history) is visible where no log would be.
- The scheduled-but-unconsumed backlog. Beat's logs say "sending due task" forever while messages rot in the broker. The heartbeat only fires on execution, so it isn't fooled.
Error tracking and heartbeats are complements, not competitors: loud in-task error reporting tells you why a run failed; the heartbeat tells you a run didn't happen at all. You want both — the same split we recommend for Vercel crons and every other scheduler.
Set it up in ten minutes
- Create a CronAlert account — heartbeat monitors are on the Pro plan ($5/mo, 100 monitors, 1-minute checks).
- Add the beat canary first: a 5-minute periodic task pinging a heartbeat monitor with a 5-minute expected interval.
- Add a heartbeat per business-critical periodic task — digests, billing runs, backups, cleanup jobs — with the expected interval matching each schedule.
- Ping last, success path only; use the decorator to stay DRY.
- Route alerts to Slack or email, then kill Beat on staging and confirm the canary alert actually arrives — a fire drill for your scheduler.
Frequently asked questions
How do I know if Celery Beat is running?
Schedule a canary task every 5 minutes that pings a heartbeat. Pings arriving prove Beat, the broker, and at least one worker are all alive; pings stopping alert you within minutes.
How do I monitor individual periodic tasks?
One heartbeat monitor per task, expected interval matching the schedule, ping as the task's last statement on the success path only.
What if two Beat instances run at once?
Everything fires twice. Prevent it with a singleton lock and idempotent tasks; duplicate pings at your heartbeat monitors are the cheap detection signal.
Why are tasks scheduled but never executed?
Beat publishes and forgets — dead or misrouted workers leave tasks rotting in the broker while Beat looks healthy. End-of-task heartbeats aren't fooled, because they only fire on execution.
Silence is the failure mode — make it page you
Celery Beat will never tell you it's dead; that's not a bug, it's the shape of every scheduler. Put a canary on the chain, a heartbeat on each task that matters, and the silence that used to cost you days now costs you minutes. Set up CronAlert and wire the canary first.
Related reading: cron job heartbeat monitoring, monitoring background workers, Django uptime monitoring, and batch job monitoring.