cron.watch
cron.watch  /  guides  /  heartbeat monitoring
Guide · Reliability

Why every daemon should phone home — and how to make it

An uptime check tells you a server answers pings. It says nothing about whether your backup ran, your queue drained, or your billing job fired. The failures that hurt most are silent — a process that died, a cron that never triggered, a worker that wedged. The only way to catch the absence of work is to expect a signal and alert when it doesn't arrive. That's a heartbeat. Here's why it matters for startups and enterprises alike, and how to wire every daemon on your fleet to send one.

~14 min read systemd · Celery · Sidekiq · asynq · Tokio · Quartz Concepts + copy-paste

01The silent-failure problem

Most monitoring answers the question "is this thing up?" — it pokes an endpoint and waits for a reply. That works for things that are supposed to answer: a web server, a load balancer, an API. But a huge share of what keeps a business running never answers anything. It's scheduled and silent:

When one of these dies, nothing pages you. There's no 500, no failed health check, no red light — because the symptom isn't an error, it's an absence. The backup simply didn't happen. You find out three weeks later, when you reach for the backup and it isn't there. By industry lore this is the most expensive class of outage precisely because time-to-detect is measured in days, not seconds.

The core asymmetry

You can't poll for something that didn't happen. A dead cron emits no traffic to scrape, no log line to match, no port to knock on. The only thing that changes when it fails is that an expected event goes missing — so the monitor has to live on the other side, waiting for a check-in that never comes.

02What a heartbeat actually is

A heartbeat — also called a dead man's switch or deadman's snitch — inverts the direction of monitoring. Instead of the monitor reaching into your job, the job reaches out to the monitor on every successful run:

It's the same logic as a railway driver's safety pedal: the system is healthy only as long as the signal keeps coming. The moment it stops, someone gets paged. With cron.watch a check-in is literally one line — curl -fsS https://cron.watch/s/<slug> — and the absence-detection, grace windows, and escalation are handled for you.

QuestionUptime / health checkHeartbeat check-in
Is the host reachable?YesIndirectly
Did the scheduled job run?NoYes
Did it run on time?NoYes
Did it finish (not hang)?NoYes — with start + exit code
Catches a deleted/disabled cron?NoYes
Works behind a firewall / no inbound?NoYes — the job dials out

The two are complements, not rivals. Uptime checks watch the things that answer; heartbeats watch the things that act. A complete picture needs both.

03Why it matters for startups

A small team has no NOC, no follow-the-sun on-call, and no spare attention. The founder is the on-call rotation. That makes silent failures disproportionately dangerous — there's nobody whose job is to notice — and it makes heartbeats disproportionately valuable, because they convert "someone has to remember to check" into "we get told."

The risk

Small team, big blast radius

  • One missed billing run can quietly stop revenue for a day.
  • A backup that's been failing for a month is discovered the day you need it.
  • You learn about the outage from a customer — the worst possible source.
Why heartbeats fit

Cheapest insurance you'll buy

  • One curl line per job — minutes to add, no agent, no infra.
  • Detection in minutes instead of days shrinks the damage.
  • Free tier covers a young stack; you pay per monitor, never per seat.

For a startup the calculation is simple: the engineering cost of adding a heartbeat is one line; the cost of not having one is a silent failure you find out about from the people you can least afford to disappoint.

04Why it matters for enterprises

At scale the problem changes shape. It's no longer "did the one backup run" — it's thousands of scheduled jobs across fleets, teams, and regions, each an unwatched corner where a silent failure can hide. Heartbeats turn that sprawl into something measurable and provable.

The same primitive, both ends of the market

A startup adds a heartbeat so a human finds out before a customer does. An enterprise adds the same heartbeat so an SLO, an auditor, and a capacity model all find out automatically. One line of curl; very different stakes.

05One daemon, one monitor, one socket heartbeat

The right granularity is one monitor per worker or daemon — not a single check for the whole box, and not one shared monitor behind a middleware that fires for every job type. Each long-lived process owns its monitor and emits its own heartbeat from inside its work loop, so an alert names the exact process that went quiet. Create the monitor once and hand the slug to that one daemon:

# one monitor for this worker → one check-in identity
curl -X POST https://api.cron.watch/v1/monitors \
  -H 'Authorization: Bearer cw_live_xxx' \
  -d '{"name":"api-worker","type":"heartbeat","schedule":"every 60s","grace":"3m"}'
# → { "checkin_url": "https://cron.watch/s/api-worker" }

The parallel-poller trap

A tempting shortcut is a sidecar timer that runs systemctl is-active && curl alongside the service. Don't. A poller that only checks is-active has no reference to the app's real work — the unit stays "active" while the daemon is deadlocked, spinning on a dead upstream, or wedged mid-batch. It proves the process hasn't exited, which is not the same as alive. Bolting on a second process to guess at the first one's health just adds a thing that can lie to you. The heartbeat has to originate from the work itself.

Socket-based liveness, the systemd way

The main app proves it's alive by sending a datagram over a socket — systemd's notify socket, a local AF_UNIX endpoint at $NOTIFY_SOCKET. With Type=notify and WatchdogSec=, the app pings WATCHDOG=1 from its real loop; miss the deadline and systemd restarts it. The same loop forwards the beat to cron.watch — one signal source driving local restart and remote alerting, with no extra process.

/etc/systemd/system/api-worker.service

[Unit]
Description=API worker
[Service]
ExecStart=/opt/api-worker
Type=notify            # app reports readiness + liveness over $NOTIFY_SOCKET
WatchdogSec=45s        # systemd restarts if no WATCHDOG=1 arrives within 45s
Restart=on-failure
NotifyAccess=main
[Install]
WantedBy=multi-user.target

…and the app, from the loop that does the work — local watchdog and remote heartbeat in the same place:

# Python — one socket beat to systemd, one to cron.watch, from the work loop
import socket, os

def sd_notify(msg):                       # local AF_UNIX datagram to systemd
    addr = os.environ.get("NOTIFY_SOCKET")
    if not addr: return
    s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
    s.connect("\0" + addr[1:] if addr[0] == "@" else addr)
    s.sendall(msg.encode())

sd_notify("READY=1")                       # primary initialization
while running:
    do_one_unit_of_work()
    sd_notify("WATCHDOG=1")                # local: systemd restarts on a miss
    cw_beat()                              # remote: cron.watch, over a cheap channel (§06)
Why the socket, not a poller

The watchdog ping is emitted inside the loop that does the work. If the work stalls, the ping stops — instantly, by construction, with nothing left to misreport. systemd owns the restart; cron.watch owns the human alert. No second process, no is-active guesswork.

Run-to-completion jobs are the other shape — one monitor each, signalling start and exit code instead of a continuous beat: curl …/s/nightly-backup/start; ./run.sh; curl …/s/nightly-backup/$?. Identical replicas are the one deliberate exception to one-per-process: point the pool at a single fleet monitor with a quorum rule (type=fleet, dimension=host, quorum=5) instead of one monitor per replica.

06Keep it cheap: tier your transport

An HTTPS request every few seconds from every worker is the expensive way to stay alive — a TLS handshake and a fresh TCP connection per beat, multiplied across a fleet, taxes both ends for no extra signal. A heartbeat should get cheaper the more often it fires. Tier it in three layers, each used at the frequency it's priced for:

ChannelCost / beatDeliveryUse for
UDP datagram1 packet, no ACKFire-and-forgetHigh-frequency liveness + intermediate markers
DNS lookup1 UDP round-tripBest-effortLiveness from egress-locked / DNS-only boxes
WebSocketOne persistent connOrdered, liveStreamed progress; a dropped socket = instant "died"
HTTPS syncTLS handshakeDurable, confirmedFull metrics every 5 min — the backstop

Why UDP for the frequent beat: no connection setup, no per-packet ACK, no retransmit, no server-side connection state — a datagram is the cheapest "I'm alive, and here's where I am" you can put on the wire. The cost of occasional loss is absorbed entirely by the 5-minute HTTPS reconcile, so you buy TCP-grade accuracy at UDP-grade cost.

# after init (HTTPS handed us the slug) — cheap UDP beat carries an intermediate marker
printf 'api-worker stage=index pct=64' | nc -u -w0 ping.cron.watch 4738   # fire-and-forget

# DNS-only box? a lookup is a UDP check-in too (no +tcp ⇒ UDP):
dig +short index-64.api-worker.dns.cron.watch

# long job with live progress? one WebSocket, no per-frame handshake:
#   wss://cron.watch/s/api-worker   →   {"stage":"index","pct":64}

07Recipes for your job framework

Same model in every stack: one monitor per worker, initialized once over HTTPS, kept alive by a cheap UDP beat that carries an intermediate marker, and reconciled by an HTTPS sync every 5 minutes (stats() is your own snapshot — processed count, queue depth, last result). Pick a language — code shows only for the tab you choose:

One monitor for the Celery worker process — init on worker_ready, UDP beat per task, HTTPS sync on a thread:

# celery_app.py — one monitor for THIS worker process
import socket, time, threading, urllib.request, json
from celery.signals import worker_ready, task_postrun

SLUG = "billing-worker"
udp  = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

def beat(marker=""):                       # cheap: fire-and-forget UDP
    udp.sendto(f"{SLUG} {marker}".encode(), ("ping.cron.watch", 4738))

def sync():                                # durable: full state every 5 min
    while True:
        urllib.request.urlopen(f"https://cron.watch/s/{SLUG}",
            data=json.dumps({"metrics": stats()}).encode(), timeout=10)
        time.sleep(300)

@worker_ready.connect
def _init(**_):                            # primary initialization, over HTTPS
    urllib.request.urlopen(f"https://cron.watch/s/{SLUG}/start")
    threading.Thread(target=sync, daemon=True).start()

@task_postrun.connect
def _beat(task=None, **_): beat(task.name) # intermediate marker per task

One monitor for the Sidekiq process — init + sync thread on startup, UDP beat from the job:

# config/initializers/cronwatch.rb — one monitor for this Sidekiq process
require "socket"; require "net/http"
SLUG = "billing-worker"
UDP  = UDPSocket.new

def beat(m = "")                         # cheap UDP beat
  UDP.send("#{SLUG} #{m}", 0, "ping.cron.watch", 4738)
end

Sidekiq.configure_server do |c|
  c.on(:startup) do                       # init (HTTPS) + 5-min sync thread
    Net::HTTP.get(URI("https://cron.watch/s/#{SLUG}/start"))
    Thread.new { loop { sleep 300
      Net::HTTP.post_form(URI("https://cron.watch/s/#{SLUG}"), stats) } }
  end
end

class BillingJob
  include Sidekiq::Job
  def perform(*args)
    do_work(*args)
    beat("done")                          # intermediate marker per run
  end
end

One monitor for the asynq worker — init, a sync goroutine, and a UDP beat per task:

const slug = "billing-worker"
udp, _ := net.Dial("udp", "ping.cron.watch:4738")

func beat(m string) { fmt.Fprintf(udp, "%s %s", slug, m) } // fire-and-forget

func main() {
    http.Get("https://cron.watch/s/" + slug + "/start")        // init, over HTTPS
    go func() {                                            // 5-min HTTPS sync
        for range time.Tick(5 * time.Minute) {
            http.Post("https://cron.watch/s/"+slug, "application/json", stats())
        }
    }()
    srv.Run(asynq.HandlerFunc(func(ctx context.Context, t *asynq.Task) error {
        err := work(ctx, t)
        beat(t.Type())                                       // intermediate marker per task
        return err
    }))
}

One monitor for the Tokio / Apalis worker — init, a sync task, and a UDP beat per job:

const SLUG: &str = "billing-worker";

#[tokio::main]
async fn main() {
    reqwest::get(format!("https://cron.watch/s/{SLUG}/start")).await.ok();  // init
    let sock = tokio::net::UdpSocket::bind("0.0.0.0:0").await.unwrap();
    sock.connect("ping.cron.watch:4738").await.unwrap();

    tokio::spawn(async {                                             // 5-min HTTPS sync
        let mut t = tokio::time::interval(Duration::from_secs(300));
        loop { t.tick().await;
            reqwest::Client::new().post(format!("https://cron.watch/s/{SLUG}"))
                .json(&stats()).send().await.ok(); }
    });

    while let Some(job) = queue.next().await {
        work(job).await;
        let _ = sock.send(format!("{SLUG} done").as_bytes()).await;         // cheap UDP beat
    }
}

One monitor for the Spring worker bean — init in @PostConstruct, UDP beat, @Scheduled sync:

@Component
public class BillingWorker {
    static final String SLUG = "billing-worker";
    final HttpClient http = HttpClient.newHttpClient();
    DatagramSocket udp;  InetAddress host;

    @PostConstruct void init() throws Exception {              // primary init, over HTTPS
        http.send(HttpRequest.newBuilder(URI.create(
            "https://cron.watch/s/" + SLUG + "/start")).build(),
            HttpResponse.BodyHandlers.discarding());
        udp  = new DatagramSocket();
        host = InetAddress.getByName("ping.cron.watch");
    }

    void beat(String m) throws Exception {                     // cheap UDP beat
        byte[] b = (SLUG + " " + m).getBytes();
        udp.send(new DatagramPacket(b, b.length, host, 4738));
    }

    @Scheduled(fixedRate = 300_000)                              // 5-min HTTPS sync
    void sync() throws Exception {
        http.send(HttpRequest.newBuilder(URI.create("https://cron.watch/s/" + SLUG))
            .POST(HttpRequest.BodyPublishers.ofString(stats())).build(),
            HttpResponse.BodyHandlers.discarding());
    }
}

One monitor for the BullMQ worker — init, a sync interval, and a UDP beat per job:

import { Worker } from "bullmq";
import dgram from "node:dgram";

const SLUG = "billing-worker";
const udp  = dgram.createSocket("udp4");
const beat = (m = "") => udp.send(`${SLUG} ${m}`, 4738, "ping.cron.watch"); // cheap UDP

await fetch(`https://cron.watch/s/${SLUG}/start`);            // primary init, HTTPS
setInterval(() =>                                            // 5-min HTTPS sync
  fetch(`https://cron.watch/s/${SLUG}`, { method: "POST", body: JSON.stringify(stats()) }),
  300_000);

new Worker("billing", async job => {
  await doWork(job);
  beat(job.name);                                            // intermediate marker per job
});

08AI-native: just ask your agent

cron.watch ships an MCP server, so if your editor or assistant (Claude Code, Cursor, Claude Desktop) is connected, you don't have to write any of the above by hand — describe the job and let the agent create the monitor and wire the check-in. The tools it drives: create_monitor, list_monitors, check_in, push_metric. Prompts that work today:

Prompt · Python / Celery“Create a cron.watch heartbeat monitor billing-worker, every 60s, grace 3m. Wire my Celery worker to init over HTTPS on worker_ready, send a UDP beat per task, and HTTPS-sync stats every 5 minutes — one monitor for the worker process.”
Prompt · systemd / socket“Convert my api-worker.service to Type=notify with a 45s watchdog, and add the sd_notify("WATCHDOG=1") call plus a cron.watch UDP beat to its main loop — one monitor, socket-based liveness.”
Prompt · Go / asynq“Set up one cron.watch monitor for my asynq worker: HTTPS init in main, a 5-minute sync goroutine, and a fire-and-forget UDP beat per task carrying the task type as the marker.”
Prompt · Transport“My worker fires HTTPS every 5s and it's hammering the server. Re-tier it: one HTTPS init, UDP beats for the frequent liveness + progress, and a single HTTPS reconcile every 5 minutes.”
Prompt · Migration“List my long-running systemd services, create one heartbeat monitor per daemon, and add a socket-based check-in to each from inside its work loop — no parallel poller.”

The same flow works from the HTTP API if you'd rather script it: POST /v1/monitors to create, then hand the returned checkin_url to the job.

09Picking an interval and grace window

The two numbers that decide signal-to-noise are the interval (how often the job checks in) and the grace (how late it's allowed to be before you're paged). Get them wrong and you either miss outages or train yourself to ignore the alerts.

10What good looks like

Do this across your daemons and the unwatched corners disappear: every scheduled thing either checks in on time or tells you the moment it doesn't.

Know your jobs ran.

Spin up a heartbeat monitor, drop one curl line into the job, and never find out about a dead cron from a customer again. 10 monitors free for 3 months.

Get early access →