macos · level 4

launchd

How Mac jobs actually run: plists, agents, daemons, and why cron is gone.

150 XP

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

  1. plutil -lint path/to.plist — validates XML syntax
  2. launchctl bootout gui/$(id -u) path/to.plist && launchctl bootstrap ... — reload cleanly
  3. launchctl print gui/$(id -u)/com.example.sync — shows last-exit, next-fire, state
  4. log 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.