launchd
How Mac jobs actually run: plists, agents, daemons, and why cron is gone.
launchd
On Linux you reach for cron, systemd timers, or @reboot scripts. On macOS all of those map to one tool: launchd. It's the first userspace process (PID 1), it starts every Apple service, and it schedules every background job on the machine. Learning launchd is unavoidable if you want anything to run reliably on a Mac.
Analogy
launchd is the building superintendent of your Mac. The super runs the boiler every morning, flips on the corridor lights at dusk, schedules the elevator inspection for the first Tuesday of the month, and — crucially — notices when the laundry-room dryer trips its breaker and goes down to flip it back. Cron is a retired super who taped a paper schedule to the wall decades ago: if the building was dark on Tuesday at 3am, the garbage pickup was simply missed, no make-up. A plist is a maintenance work order — a written ticket that names the job, says what to run, and specifies the schedule. "Agent" vs "daemon" is the ticket routing: user-level agents only run while a resident is home; system daemons run for the whole building whether anyone's in or not.
Why cron is basically gone
cron still exists on macOS, but Apple deprecated it in 10.4 and hasn't maintained it since. cron can't:
- Run a missed job if the machine was asleep
- Respect Low Power Mode or a drained battery
- Restart a crashed process
- Report exit status to anything you can query
- Run as a specific user's agent rather than root
launchd handles all of that. The cost: jobs are XML, not one-liners.
The plist
A launchd job is a property list (plist) — an XML file with a schema. The minimum is a Label and ProgramArguments:
<?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.sync</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/sync-backup.sh</string>
</array>
<key>StartInterval</key>
<integer>900</integer>
</dict>
</plist>
Save that as ~/Library/LaunchAgents/com.example.sync.plist. The filename and the Label should match.
Scheduling keys
Only four scheduling keys matter in practice:
| Key | What it does |
|---|---|
RunAtLoad |
Run once as soon as launchd loads the job (typically at login or boot) |
StartInterval |
An integer — seconds between runs. 900 = every 15 min. |
StartCalendarInterval |
A dict of Hour, Minute, Weekday, Day, Month. Cron-style. |
KeepAlive |
Run continuously. Relaunch if the process exits. |
Pick one, not two. Setting both StartInterval and StartCalendarInterval is undefined behaviour.
<!-- Every day at 03:00 -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>3</integer>
<key>Minute</key><integer>0</integer>
</dict>
If StartCalendarInterval is an array, launchd treats each entry as an independent trigger — useful for "every weekday at 9am and 5pm".
Domains: agent vs daemon
This trips up almost every newcomer. launchd has multiple domains — execution contexts with different privileges and lifetimes.
| Path | Domain | Runs as | Lifetime |
|---|---|---|---|
~/Library/LaunchAgents/ |
user | logged-in user | only while that user is logged in |
/Library/LaunchAgents/ |
gui | each logged-in user | while any user is logged in |
/Library/LaunchDaemons/ |
system | root | boot until shutdown, no user needed |
/System/Library/LaunchDaemons/ |
system (Apple) | root | same, but don't touch |
Rule of thumb: if it's your personal tool, put it in ~/Library/LaunchAgents/. You don't need sudo, it doesn't need root, and it only runs while you're logged in. Only use a system daemon if the job genuinely needs root or must run before login.
Loading and unloading
The classic commands:
launchctl load ~/Library/LaunchAgents/com.example.sync.plist
launchctl unload ~/Library/LaunchAgents/com.example.sync.plist
launchctl list | grep com.example.sync
Modern macOS (10.10+) added a domain-aware syntax that's clearer once you internalize it:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.sync.plist
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.example.sync.plist
launchctl print gui/$(id -u)/com.example.sync
launchctl kickstart -k gui/$(id -u)/com.example.sync # restart now
gui/501 is "the gui domain for uid 501" (your user). system is the root domain.
Capturing output
By default launchd swallows stdout/stderr. Add:
<key>StandardOutPath</key>
<string>/tmp/com.example.sync.out</string>
<key>StandardErrorPath</key>
<string>/tmp/com.example.sync.err</string>
Then tail -f /tmp/com.example.sync.err while you debug. Once the job is stable, point these at ~/Library/Logs/ or remove them.
Debugging a broken job
plutil -lint path/to.plist— validates XML syntaxlaunchctl bootout gui/$(id -u) path/to.plist && launchctl bootstrap ...— reload cleanlylaunchctl print gui/$(id -u)/com.example.sync— shows last-exit, next-fire, statelog show --predicate 'subsystem == "com.apple.xpc.launchd"' --last 5m— kernel view
If a job fails to start, look at launchctl print first. The "state" field and "last exit code" are always the answer.
Why this matters
Anything on your Mac that runs without you pressing a button — Time Machine, Spotlight, Dropbox, your own deploy cron-replacement — runs through launchd. Know the plist keys and know the domains; the rest is syntax.