Skip to content
daniel@cosenza:~/blog
macOSDeep Dive April 17, 2026 3 min read

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.