Skip to content
macOSDeep Dive Published Updated 6 min readViews unavailable

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.

Where launchd itself came from

Dave Zarzycki designed and wrote launchd at Apple, and it first shipped in Mac OS X 10.4 Tiger in 2005 — Apple’s own stated goal at the time was explicit and specifically ambitious: replace essentially every other mechanism OS X used to launch and supervise processes, not add one more option alongside the existing patchwork. That’s a genuinely unusual scope for a single new subsystem to target, and it’s exactly why launchd absorbed responsibilities as varied as cron’s scheduling, inetd’s on-demand socket activation, and init’s own PID-1 boot supervision into one unified daemon and configuration format rather than launching as a narrower, single-purpose replacement for just one of them.

launchd as the XPC service broker

Beyond the daemon and agent model covered above, launchd also plays a central role in XPC, Apple’s lightweight interprocess communication mechanism — launchd is what actually starts an XPC service on demand when a client first connects to it, and supervises its lifecycle afterward, the same on-demand-activation capability inherited conceptually from inetd’s socket-triggered launching but applied to structured, typed IPC connections rather than raw network sockets. This is why many of Apple’s own system services and helper processes never appear as constantly-running background processes in Activity Monitor at all — they’re registered with launchd as XPC services, launched only when something actually needs to talk to them, and allowed to exit again once idle, with launchd itself bearing the responsibility for starting them back up the next time they’re needed.

Why PID 1 status specifically matters

Being PID 1 isn’t merely a numbering convention — it carries a specific Unix responsibility: PID 1 becomes the adoptive parent of any orphaned process whose original parent has exited without reaping it, and PID 1 exiting at all is treated by the kernel as a fatal, unrecoverable system event rather than an ordinary process termination. This is exactly why launchd’s own stability is so foundational to the rest of macOS working at all — every other daemon, agent, and ultimately every user process on the system depends, directly or transitively, on a PID-1 process that’s expected to never crash or exit under normal operation, which is a meaningfully higher reliability bar than any individual daemon or agent launchd itself supervises needs to meet, and a large part of why launchd’s own codebase has historically been held to unusually conservative, heavily reviewed change standards relative to less foundational parts of the operating system.

Related:

Sources:

Comments