LaunchAgents: The macOS Cron Nobody Uses (And Why You Should)
Every macOS machine ships with a task scheduler more capable than cron. It restarts failed jobs, fires missed schedules after sleep, handles stdout logging natively, and doesn't require memorizing five-field time syntax. It's called launchd, and almost nobody outside Apple's own engineering teams uses it.
If you're running database backups, health checks, log rotation, or any recurring task on a Mac, launchd with LaunchAgents is the correct tool. Cron works until your laptop sleeps through a backup window and you don't notice for a week.
What launchd Actually Is
launchd is the process manager that starts everything on macOS. It's PID 1 — the first process that runs at boot, responsible for starting system services, login items, and user-level background tasks. Apple replaced the traditional Unix init system with launchd in Mac OS X Tiger (2005), and it has been the only supported way to run scheduled and persistent background processes ever since.
There are two layers that matter for solo builders:
- LaunchDaemons: System-wide jobs in
/Library/LaunchDaemons/. Run as root, start at boot before anyone logs in. You need these for services that should survive logouts — database servers, tunnels, monitoring. - LaunchAgents: Per-user jobs in
~/Library/LaunchAgents/. Run as your user, start at login. These handle everything tied to your session — backups, sync scripts, health checks, periodic cleanup.
Both are configured with plist files — XML property lists. The format is verbose compared to a crontab line, but the verbosity buys you features cron doesn't have.
Why Not Cron
Cron works on macOS. You can crontab -e and schedule jobs the same way you would on any Unix system. But cron on macOS has three problems that launchd solves.
First, cron doesn't handle sleep. If your Mac is asleep at 2 AM when a backup is scheduled, that backup doesn't run. It doesn't run when the machine wakes up, either. It's gone. launchd with StartCalendarInterval fires missed jobs as soon as the machine wakes. For a laptop that sleeps 12-16 hours a day, this is the difference between backups that work and backups that run when they feel like it.
Second, cron gives you no process supervision. If a cron job crashes, it crashes. launchd with KeepAlive restarts the process automatically. With ThrottleInterval you can control how aggressively it retries. For long-running services like a local API server or a tunnel, this is a watchdog you get for free.
Third, cron logs go wherever you redirect them (or nowhere). launchd routes stdout and stderr to files you specify in the plist. No >> /tmp/backup.log 2>&1 appended to every line. The logging configuration lives with the job definition.
Anatomy of a Plist
A LaunchAgent plist is an XML file with a specific structure. Here's a minimal example — a script that runs every day at 5 AM:
<?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.local.daily-backup</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/me/scripts/backup.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>5</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/tmp/daily-backup.stdout.log</string>
<key>StandardErrorPath</key>
<string>/tmp/daily-backup.stderr.log</string>
</dict>
</plist>
The key fields: Label is a unique identifier (reverse-DNS convention). ProgramArguments is the command as an array of strings — not a single string with spaces, which is a common mistake. StartCalendarInterval is the schedule. StandardOutPath and StandardErrorPath are where output goes.
Save this as ~/Library/LaunchAgents/com.local.daily-backup.plist. The filename should match the Label. Then load it:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.local.daily-backup.plist
Real Examples
Here are three LaunchAgents from actual production use. Not hypothetical. These run on hardware right now.
PostgreSQL backup, every 6 hours:
<?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.local.pg-backup</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/opt/postgresql@16/bin/pg_dump</string>
<string>-U</string>
<string>postgres</string>
<string>-Fc</string>
<string>mydb</string>
<string>-f</string>
<string>/Users/me/backups/mydb.dump</string>
</array>
<key>StartInterval</key>
<integer>21600</integer>
<key>StandardErrorPath</key>
<string>/tmp/pg-backup.err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/opt/postgresql@16/bin:/opt/homebrew/bin:/usr/bin</string>
</dict>
</dict>
</plist>
Note two things: the full path to pg_dump, and the explicit PATH in EnvironmentVariables. launchd doesn't source your shell profile. If you use pg_dump without the full path, it won't be found. This catches everyone at least once.
Health check, every 5 minutes:
<?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.local.healthcheck</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/curl</string>
<string>-sf</string>
<string>--max-time</string>
<string>10</string>
<string>http://localhost:2368/ghost/api/admin/site/</string>
</array>
<key>StartInterval</key>
<integer>300</integer>
<key>StandardOutPath</key>
<string>/tmp/healthcheck.log</string>
<key>StandardErrorPath</key>
<string>/tmp/healthcheck.err.log</string>
</dict>
</plist>
StartInterval fires every N seconds — 300 for five minutes. Unlike StartCalendarInterval, which is clock-based, StartInterval is relative to when the agent was loaded. For health checks where exact clock alignment doesn't matter, this is cleaner than specifying 288 time slots.
Long-running tunnel with watchdog:
<?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.local.cloudflare-tunnel</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/cloudflared</string>
<string>tunnel</string>
<string>--config</string>
<string>/Users/me/.cloudflared/config.yml</string>
<string>run</string>
</array>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/tmp/cloudflared.log</string>
<key>StandardErrorPath</key>
<string>/tmp/cloudflared.err.log</string>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
KeepAlive tells launchd to restart the process if it exits. ThrottleInterval sets a 10-second minimum between restarts, preventing a crash loop from pegging your CPU. RunAtLoad starts the job immediately when the agent is loaded, rather than waiting for the first scheduled interval. This trio — KeepAlive, ThrottleInterval, RunAtLoad — turns launchd into a process supervisor comparable to systemd on Linux.
The Gotchas
This is where launchd costs you time if you go in unprepared.
launchctl load is deprecated. You'll find it in every tutorial written before 2022. It still works, but Apple replaced it with launchctl bootstrap and launchctl bootout in macOS Ventura. The new syntax:
# Load a LaunchAgent
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.local.my-job.plist
# Unload a LaunchAgent
launchctl bootout gui/$(id -u)/com.local.my-job
# Check status
launchctl print gui/$(id -u)/com.local.my-job
Note the asymmetry: bootstrap takes the file path, bootout takes the label. This is not intuitive. You will type it wrong the first three times.
Plist validation is silent and brutal. A malformed plist doesn't produce an error on load — the job loads, appears in launchctl list, and never runs. Or it runs once and stops. Or it crashes with exit code 78 and the only clue is a cryptic message in Console.app. Before loading any plist, validate it:
plutil -lint ~/Library/LaunchAgents/com.local.my-job.plist
If plutil says it's OK and the job still won't run, check the exit code:
launchctl print gui/$(id -u)/com.local.my-job | grep "last exit"
Exit code 78 means the plist itself has a configuration error that launchd doesn't like. Exit code 127 means the binary wasn't found — almost always a PATH issue.
launchd doesn't source your shell profile. This is the single most common failure. Your script works in the terminal because your .zshrc sets up PATH, Homebrew, pyenv, nvm, and everything else. launchd runs with a minimal environment. No /opt/homebrew/bin. No ~/.local/bin. If your script depends on anything installed via Homebrew, nvm, pyenv, or uv, you need to either use absolute paths to every binary or set EnvironmentVariables in the plist.
I set PATH explicitly in every plist now. The 30 seconds it takes to add the key is cheaper than the 45 minutes you'll spend wondering why a script that works in the terminal fails silently as a LaunchAgent.
Log files don't rotate. StandardOutPath and StandardErrorPath grow forever. launchd will not truncate them. A health check logging every 5 minutes will produce a log file that grows by ~50KB per day — manageable for a while, invisible until it isn't. Add a LaunchAgent that rotates your other LaunchAgents' logs. Or truncate in the scripts themselves.
LaunchAgents vs the Alternatives
| Feature | cron | LaunchAgents | systemd (Linux) |
|---|---|---|---|
| Missed job recovery | No | Yes | Yes (with Persistent=) |
| Process supervision | No | Yes (KeepAlive) | Yes (Restart=) |
| Logging config | Manual redirect | Built-in (stdout/stderr paths) | journald |
| Syntax | 5-field cron expression | XML plist | INI-style unit file |
| Tooling | crontab -e | launchctl + plutil | systemctl + journalctl |
| Learning curve | Low | Medium | Medium |
| macOS native | Tolerated | Yes | N/A |
LaunchAgents sit between cron's simplicity and systemd's power. The XML is more painful to write than either a crontab line or a systemd unit file. But on macOS, launchd is the system. Cron is a guest that Apple tolerates but doesn't invest in. When something goes wrong at the OS level, launchd jobs get Apple's attention. Cron doesn't.
Managing the Fleet
Once you have more than three or four LaunchAgents, you need a way to see what's running. Here are the commands that matter:
# List all your LaunchAgents and their status
launchctl list | grep com.local
# See detailed info about a specific job
launchctl print gui/$(id -u)/com.local.daily-backup
# Kick a job to run right now (without waiting for schedule)
launchctl kickstart gui/$(id -u)/com.local.daily-backup
# Stop and remove a job
launchctl bootout gui/$(id -u)/com.local.daily-backuplaunchctl kickstart is the one you'll use most during development. It triggers an immediate run of any loaded agent without changing its schedule. Test your plist, check the logs, iterate. No waiting for the next scheduled interval.
For monitoring, I keep it simple: a script that checks the last exit code of every loaded agent and flags anything non-zero. Run it as its own LaunchAgent on a 30-minute interval. The watchdog watches the watchdogs.
The Solo Builder Case
LaunchAgents matter for solo builders because the alternative is remembering to do things. Database backups, SSL certificate renewals, disk space checks, stale container cleanup — these are tasks that need to happen reliably whether you're paying attention or not. A ten-person team has someone whose implicit job includes noticing that backups stopped. A solo builder has LaunchAgents.
The setup cost is real. Writing XML plist files is not fun. The launchctl CLI changes across macOS versions in ways that break tutorials. The debugging experience is worse than it should be for a first-party Apple technology.
But the reliability is real too. I've had LaunchAgents running for months without intervention — surviving sleep cycles, OS updates, and restarts. The jobs that matter run when they're supposed to, and the ones that crash get restarted before I notice they were down.
Cron is fine for a Linux server that never sleeps. For a Mac that travels in a backpack, closes its lid at midnight, and opens again at 7 AM in a different timezone, launchd is the tool that was built for the job.