Understanding launchd: macOS's Init System and Service Manager
How launchd unified boot-time initialization, service supervision, and scheduled tasks into a single declarative system on macOS.
macOS replaced the traditional BSD-derived init and its patchwork of cron, inetd, and startup scripts with a single unified system: launchd. Introduced in Mac OS X 10.4, it’s PID 1 on every Mac, and it absorbed the responsibilities of half a dozen separate Unix subsystems into one declarative, XML-configured daemon.
One daemon, several jobs
Before launchd, a Unix-like system needed init for boot-time process supervision, cron for scheduled tasks, inetd/xinetd for on-demand network service activation, and SystemStarter scripts for ordered startup — each with its own configuration format and no shared visibility into what the others were doing. launchd folded all of that into one daemon and one configuration format: the property list (plist).
launchctl list | head
Anatomy of a launchd plist
A launch daemon or agent is described by an XML property list specifying what to run and under what conditions:
<?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.danielcosenza.myagent</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/myagent</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
Label is the job’s unique identifier, used everywhere in launchctl. RunAtLoad starts the job as soon as it’s loaded; KeepAlive tells launchd to restart it automatically if it exits — the mechanism behind “this daemon should basically never stop running.”
Daemons vs. agents: system-wide vs. per-user
launchd distinguishes daemons, which run as root with no attached user session, from agents, which run in the context of a logged-in user (and can, unlike daemons, interact with the window server for UI). This split is reflected directly in where their plists live:
/Library/LaunchDaemons/ — system daemons, all users, no GUI session
/Library/LaunchAgents/ — system agents, loaded per logged-in user
~/Library/LaunchAgents/ — per-user agents, this user only
/System/Library/LaunchDaemons/ — Apple's own system daemons
A backup agent that needs to show a notification belongs in LaunchAgents (it needs a user session and GUI access); a network daemon that should run regardless of whether anyone’s logged in belongs in LaunchDaemons.
Loading, unloading, and the modern launchctl verbs
Older documentation refers to launchctl load/unload; recent macOS versions have moved to the more explicit bootstrap/bootout verbs, which operate on a specific domain (the system domain, or a specific user’s GUI domain):
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.mydaemon.plist
sudo launchctl bootout system /Library/LaunchDaemons/com.example.mydaemon.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.myagent.plist
launchctl print gui/$(id -u)/com.example.myagent
launchctl print is the modern replacement for piecing together a job’s state from list — it dumps a job’s full runtime state: whether it’s running, its PID, its last exit status, and every key from its plist.
Triggered activation: sockets and watch paths
Like inetd before it, launchd can start a job on demand rather than keeping it running continuously — binding a socket on the job’s behalf and only actually launching the executable when a connection arrives, or watching a path and launching when it changes:
<key>Sockets</key>
<dict>
<key>Listeners</key>
<dict>
<key>SockServiceName</key>
<string>8080</string>
</dict>
</dict>
<key>WatchPaths</key>
<array>
<string>/Users/daniel/Dropbox</string>
</array>
Scheduled jobs: launchd’s cron replacement
StartCalendarInterval replaces cron entirely for scheduled execution, expressed as a dictionary of the calendar fields that should trigger the job:
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>9</integer>
<key>Minute</key><integer>0</integer>
</dict>
Reading logs
Modern macOS routes launchd job output through the unified logging system rather than flat log files by default, queryable with log show:
log show --predicate 'process == "myagent"' --last 1h
Redirecting StandardOutPath/StandardErrorPath explicitly in the plist is still supported and often simpler for a straightforward daemon that just needs a plain log file rather than integration with the unified logging system’s structured predicates.
Why the unification matters
Having one supervisor for boot-time daemons, user session agents, on-demand socket activation, and scheduled tasks means one consistent way to answer “is this thing running, and why (or why not)” — launchctl print — rather than needing to separately check ps, crontab -l, and inetd.conf depending on which subsystem happened to own a given job. That consolidation, more than any single feature, is what launchd actually replaced.