Every scheduler has a way of failing silently, and macOS's has more than most. launchd replaced cron with a richer model (per-user agents, system daemons, calendar and interval triggers, automatic restarts) and in doing so added failure modes cron never had. A LaunchAgent doesn't run if nobody is logged in. A script that works perfectly in Terminal fails under launchd because launchd never read your shell profile and restic isn't on its PATH. A job that backs up ~/Documents is denied by privacy controls with no error you'll see. A nightly schedule falls while the Mac is asleep, and whether it runs on wake, runs late, or never runs depends on how the Mac went to sleep.

None of this is visible from the schedule itself: launchctl list says the job is loaded, and that's all it says. What you need is a signal that the job did its work, and that's what a heartbeat is. This guide gives you a wrapper that pings a heartbeat only after verifying success, the plist that runs it, a Time Machine freshness check, a canary for launchd itself, and the macOS-specific traps that make all of this necessary.

Agents, daemons, and who's logged in

launchd runs two kinds of jobs. LaunchAgents live in ~/Library/LaunchAgents (per user) or /Library/LaunchAgents (all users) and run inside a login session, with access to the user's keychain, iCloud, and GUI apps. LaunchDaemons live in /Library/LaunchDaemons, run as root or a specified user, and start at boot with no login required. The distinction is the first silent failure: a Mac mini in a closet reboots after a power cut, comes back to the login screen with FileVault waiting for a password, and every LaunchAgent on it is now paused indefinitely while every daemon runs fine. If a job must survive unattended reboots, make it a daemon, or enable automatic login and accept the security trade-off. Either way, the heartbeat tells you when it stopped.

The wrapper: verify, then ping

Create a heartbeat monitor in CronAlert with the expected interval set to the job's schedule (24 hours for a nightly job) and a grace period that covers its slowest normal run. Then wrap the job so the ping fires only on verified success:

#!/bin/bash
# /usr/local/bin/nightly-backup.sh — run by launchd; pings CronAlert only on verified success
set -euo pipefail

# launchd does not load your shell profile. Set PATH explicitly or Homebrew tools are "not found".
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
HEARTBEAT="https://cronalert.com/api/heartbeat/<token>"
LOG="$HOME/Library/Logs/nightly-backup.log"

{
  echo "=== $(date) start"
  # Keep the Mac awake for the duration (idle sleep would otherwise interrupt it)
  caffeinate -i restic -r "$RESTIC_REPOSITORY" backup "$HOME/Documents" "$HOME/Pictures"

  # Verify: the newest snapshot is from the last hour, not a stale one from yesterday
  newest=$(restic -r "$RESTIC_REPOSITORY" snapshots --latest 1 --json | python3 -c 'import sys,json,datetime; s=json.load(sys.stdin)[0]["time"][:19]; print(int((datetime.datetime.utcnow()-datetime.datetime.fromisoformat(s)).total_seconds()))')
  [ "$newest" -lt 3600 ] || { echo "newest snapshot is ${newest}s old — not pinging"; exit 1; }

  curl -fsS -m 10 -X POST "$HEARTBEAT" >/dev/null
  echo "=== $(date) ok"
} >> "$LOG" 2>&1

Three things in there are macOS-specific. The explicit PATH is not optional: launchd's default is /usr/bin:/bin:/usr/sbin:/sbin, so anything from Homebrew is invisible without it, and "command not found" under launchd produces no notification. caffeinate -i prevents idle sleep while the job runs, so a two-hour backup isn't suspended at the ninety-minute mark. And the log goes to ~/Library/Logs, where Console.app can read it, because launchd's own output capture (StandardOutPath) is per job and easy to forget. The verification step is the same discipline as any heartbeat: don't ping because the command exited zero, ping because the result is what you wanted.

The plist

Save this as ~/Library/LaunchAgents/com.example.nightly-backup.plist (or under /Library/LaunchDaemons with a UserName key for a daemon), then load it with launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.nightly-backup.plist (launchctl load still works on current macOS but is the legacy form):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>com.example.nightly-backup</string>
  <key>ProgramArguments</key><array>
    <string>/bin/bash</string>
    <string>/usr/local/bin/nightly-backup.sh</string>
  </array>
  <key>StartCalendarInterval</key><dict>
    <key>Hour</key><integer>2</integer>
    <key>Minute</key><integer>0</integer>
  </dict>
  <key>EnvironmentVariables</key><dict>
    <key>RESTIC_REPOSITORY</key><string>sftp:[email protected]:/backups/mac</string>
    <key>RESTIC_PASSWORD_FILE</key><string>/Users/you/.config/restic/password</string>
  </dict>
  <key>StandardOutPath</key><string>/Users/you/Library/Logs/nightly-backup.launchd.log</string>
  <key>StandardErrorPath</key><string>/Users/you/Library/Logs/nightly-backup.launchd.log</string>
</dict></plist>

Check it loaded with launchctl list | grep com.example; the second column is the last exit status (0, or a non-zero code, or - if it hasn't run). Note that this status is all launchd will ever tell you about the job's outcome, and it only reflects the wrapper's exit code, not whether the backup contained anything. launchctl print gui/$(id -u)/com.example.nightly-backup shows the full state, including the next scheduled run.

Sleep, and the schedule that never came

launchd's behavior around sleep is the second silent failure. For StartCalendarInterval, a schedule that falls while the Mac is asleep is coalesced into a single run when it wakes, so a 2 AM job on a laptop that slept at midnight runs at 8 AM when you open the lid; late, but it runs. If the Mac was shut down at 2 AM, the run is skipped entirely; launchd has no anacron-style catch-up for missed days. For StartInterval, the timer doesn't advance during sleep in a way you can rely on either.

For a Mac that's supposed to act as a server, the fix is to keep it awake or wake it on schedule: in System Settings → Energy (or Battery on a laptop), prevent automatic sleeping when the display is off, and for a backup window use sudo pmset repeat wakeorpoweron MTWRFSU 01:55:00 so the machine is awake five minutes before the job. The heartbeat's grace period then covers the normal late-on-wake case for a laptop: set a daily job's grace to the longest you'd tolerate the Mac being closed before you want to hear about it, and the job that never ran because the lid stayed shut becomes an alert rather than a surprise.

Full Disk Access, and the error you never see

Since macOS Mojave, access to Desktop, Documents, Downloads, external volumes, and several app data folders is gated per application by privacy controls. Under launchd, "the application" is the interpreter running your script, usually /bin/bash or /usr/bin/python3, and it has no access by default. The result is Operation not permitted on every protected file, which a backup tool may report as a warning and carry on, backing up nothing that matters, and which a set -e wrapper turns into a non-zero exit and therefore no ping. To grant it, open System Settings → Privacy & Security → Full Disk Access, click the plus button, press Command-Shift-G, and add /bin/bash (or whichever interpreter the plist names). Grant it to the specific interpreter you run, and run the job once by hand with launchctl kickstart -k gui/$(id -u)/com.example.nightly-backup to confirm it completes before trusting the schedule.

Time Machine: check the result, not the job

Time Machine runs on its own schedule with no completion hook, so instead of wrapping it, check its outcome. A LaunchAgent every 15 minutes reads the latest backup's timestamp and pings only if it's recent:

#!/bin/bash
# /usr/local/bin/tm-health.sh — every 15 min via launchd (StartInterval 900)
export PATH="/usr/bin:/bin:/usr/sbin:/sbin"
HEARTBEAT="https://cronalert.com/api/heartbeat/<token>"
MAX_AGE=$((26 * 3600))

latest=$(tmutil latestbackup 2>/dev/null) || exit 0     # no backup at all → no ping
# Backup paths end in a timestamp like 2026-10-04-020512 (or .backup on APFS destinations)
stamp=$(basename "$latest" | sed -E 's/\.backup$//' | sed -E 's/^([0-9]{4})-([0-9]{2})-([0-9]{2})-([0-9]{2})([0-9]{2})([0-9]{2})$/\1-\2-\3 \4:\5:\6/')
age=$(( $(date +%s) - $(date -j -f "%Y-%m-%d %H:%M:%S" "$stamp" +%s) ))
[ "$age" -lt "$MAX_AGE" ] && curl -fsS -m 10 -X POST "$HEARTBEAT" >/dev/null
exit 0

Create its heartbeat with a 15-minute expected interval. When the destination disk is unmounted, the network share is gone, or Time Machine was switched off by an update, the latest backup ages past 26 hours, the script stops pinging, and you hear about it within about half an hour. tmutil latestbackup may need Full Disk Access for the interpreter too; test it once by hand.

A canary for launchd itself

If every heartbeat on a Mac goes silent at once, you want to know whether the jobs broke or the Mac did. Add one unconditional ping: a LaunchDaemon with StartInterval 900 whose only program is curl -fsS -m 10 -X POST https://cronalert.com/api/heartbeat/<token>, and a 15-minute heartbeat to match. Being a daemon, it runs whether anyone is logged in or not. Then the pattern reads itself: canary silent means the Mac is off, asleep, or offline; canary fine and a job's heartbeat silent means that job. If the Mac also serves anything over HTTP (a Plex server, a home web app), an external HTTP or TCP monitor on it gives you the same split from outside, on the free plan.

Frequently asked questions

Why did my job stop even though the plist is loaded?

Nobody logged in (agents only run in a session), PATH missing Homebrew, Full Disk Access denied, or the schedule fell during sleep or shutdown. The heartbeat reports all four the same way.

Agent or daemon?

Daemon if it must run unattended after a reboot; agent if it needs your user context. Either way, wrap it with a heartbeat.

How do I monitor Time Machine?

Check tmutil latestbackup's age every 15 minutes and ping only when it's fresh.

Does launchd tell me when a job fails?

Only the last exit code in launchctl list, and only if you look. It never notifies.

Which plan?

Heartbeats are Pro ($5/mo). HTTP checks on anything the Mac serves are free.

The Mac that runs your backups should report to something

launchd will run your job faithfully for years and never say a word when it stops. A wrapper that verifies before it pings, a plist with an explicit PATH, Full Disk Access for the interpreter, a wake schedule for the server Macs, and one canary daemon turn every way a launchd job dies into an alert the next morning. Create an account, upgrade to Pro, and give the Mac mini in the closet a heartbeat. Related reading: cron job heartbeat monitoring, monitoring systemd timers and Windows Task Scheduler tasks for the other schedulers, monitoring scheduled backups, and choosing a heartbeat grace period.