Introduction to macOS Launchd Services
Launchd is the unified service management framework introduced by Apple in macOS (originally Mac OS X Tiger) to replace the traditional init, rc, SystemStarter, and cron mechanisms. It is the first process that runs when macOS boots (PID 1), and it is responsible for starting, stopping, and managing daemons, agents, and scheduled tasks across the entire operating system.
For developers building macOS applications, command-line tools, or background services, understanding Launchd is essential. Whether you need to run a backup script every night, keep a web server alive, or start a helper process when a user logs in, Launchd is the canonical, Apple-sanctioned way to do it.
What Launchd Actually Does
At its core, Launchd reads property list (plist) files that describe how, when, and under what conditions a program should run. These plists define:
- The program to execute and its arguments.
- When to run it — on demand, at boot, at login, on a schedule, or when a file changes.
- Keep-alive conditions — whether Launchd should restart the process if it exits.
- Environment variables, working directories, and I/O redirection.
- Resource limits such as memory and CPU caps.
Launchd groups jobs into two broad categories: daemons (system-wide, run as root or another system user, independent of any GUI login) and agents (run on behalf of a logged-in user, in that user's session).
Why Launchd Matters for Developers
Before Launchd, macOS developers relied on a patchwork of tools. Shell scripts in /etc/rc.local, cron jobs, and custom startup wrappers all competed for control. Launchd consolidates all of this into a single, declarative system with several important advantages:
- Reliability: Launchd monitors your process and can automatically restart it if it crashes, with configurable throttling.
- Efficiency: On-demand jobs only consume resources when triggered — for example, when a network port receives a connection or a file appears in a directory.
- Consistency: The same plist format works for system daemons, user agents, and scheduled tasks.
- Integration: Launchd is deeply integrated with macOS security, sandboxing, and the Service Management framework (
SMAppServiceon modern macOS). - Observability: The
launchctlcommand-line tool and Console.app provide visibility into job state and logs.
If you are shipping a macOS app that needs a background helper, or you are an SRE maintaining services on Mac fleets, Launchd is the mechanism you will use.
Daemons vs. Agents: Where Plists Live
The location of your plist file determines whether Launchd treats it as a system daemon or a user agent. This distinction is critical because it affects privileges, lifecycle, and when the job becomes eligible to run.
System Daemons
System daemons run independently of any user login. They are loaded at boot time and typically run as root (though you can specify another user). Place these plists in:
/Library/LaunchDaemons/com.example.mydaemon.plist
The plist filename must use a reverse-DNS naming convention (e.g., com.company.product) and the file must be owned by root:wheel with permissions 0644. Launchd will refuse to load plists with insecure ownership.
User Agents
User agents run inside a user's login session and have access to that user's environment, including the GUI. They are loaded when the user logs in. There are several valid locations:
/Library/LaunchAgents— available to all users, loaded for each user at login.~/Library/LaunchAgents— specific to a single user./System/Library/LaunchAgents— reserved for Apple's own agents; do not modify.
For most developer use cases — helper apps, menu bar tools, personal automation — a user agent in ~/Library/LaunchAgents is the right choice.
Anatomy of a Launchd Plist
Launchd plists are XML property lists. While you can write them by hand, tools like plutil can validate and convert them. Here is a complete, annotated example of a user agent that runs a Python script every 30 minutes:
<?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-agent</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/dev/scripts/sync_files.py</string>
<string>--verbose</string>
</array>
<key>StartInterval</key>
<integer>1800</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/com.example.sync-agent.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/com.example.sync-agent.err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
Key Properties Explained
Understanding the most common plist keys is the foundation of working with Launchd:
- Label — A unique reverse-DNS identifier for the job. It must match the plist filename (minus the
.plistextension). - ProgramArguments — An array where the first element is the executable path and subsequent elements are arguments. Note that Launchd does not invoke a shell, so shell features like pipes and globbing do not work here.
- Program — Optional alternative to the first element of
ProgramArguments. You can use this for the executable andProgramArgumentsfor just the args. - RunAtLoad — If true, Launchd runs the job immediately when the plist is loaded, in addition to any other triggers.
- StartInterval — Runs the job every N seconds. Launchd guarantees a minimum interval of 10 seconds.
- StartCalendarInterval — Cron-like scheduling by calendar time (minute, hour, day, weekday, month).
- KeepAlive — Controls whether Launchd restarts the job after it exits. Can be a boolean or a dictionary of conditions.
- StandardOutPath / StandardErrorPath — Redirect stdout and stderr to files. Without these, output is discarded.
- WorkingDirectory — Sets the current working directory for the process.
Scheduling with StartCalendarInterval
For cron-style scheduling, use StartCalendarInterval. The following example runs a backup script every weekday at 2:30 AM:
<key>StartCalendarInterval</key>
<dict>
<key>Weekday</key>
<integer>1</integer>
<key>Hour</key>
<integer>2</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
To run on multiple days, supply an array of integers. For example, to run Monday through Friday:
<key>StartCalendarInterval</key>
<dict>
<key>Weekday</key>
<array>
<integer>1</integer>
<integer>2</integer>
<integer>3</integer>
<integer>4</integer>
<integer>5</integer>
</array>
<key>Hour</key>
<integer>2</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
If the machine is asleep or off at the scheduled time, Launchd will run the job once when the system wakes up — a significant improvement over cron, which simply skips missed executions.
KeepAlive Strategies
The KeepAlive key is one of the most powerful features of Launchd. In its simplest form, a boolean true tells Launchd to restart the process whenever it exits:
<key>KeepAlive</key>
<true/>
However, blindly restarting a crashing process can create a tight loop. Launchd imposes a 10-second throttle between restarts by default, but for finer control, use the dictionary form with conditions:
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
<key>AfterInitialDemand</key>
<true/>
</dict>
This tells Launchd to restart the job only if it exits with a non-zero status. Other useful conditions include:
- Crashed (
true) — Restart only if the process crashed (was killed by a signal). - PathState — Restart when a specified file appears or disappears.
- OtherJobEnabled — Keep alive only while another named job is enabled.
You can also control the throttle interval explicitly:
<key>ThrottleInterval</key>
<integer>30</integer>
On-Demand Jobs with WatchPaths and Sockets
Launchd does not always need a timer. It can launch your program in response to filesystem events or network connections, which is far more efficient than polling.
WatchPaths
The WatchPaths key launches the job whenever the contents of a specified directory change:
<key>WatchPaths</key>
<array>
<string>/Users/dev/incoming</string>
<string>/Users/dev/.config/triggers</string>
</array>
This is useful for file-processing pipelines — for example, automatically converting images dropped into a folder.
QueuedDirectories
Similar to WatchPaths, but Launchd will keep triggering the job until the directory is empty, making it ideal for queue-based processing.
Listening Sockets
Launchd can hold open a network socket and launch your program only when a connection arrives. This is how ssh and many other macOS services operate. Define a Sockets dictionary:
<key>Sockets</key>
<dict>
<key>Listener</key>
<dict>
<key>SockServiceName</key>
<string>8080</string>
<key>SockType</key>
<string>stream</string>
<key>SockProtocol</key>
<string>tcp</string>
</dict>
</dict>
When a connection arrives, Launchd starts your program and passes the accepted socket as file descriptors starting at SD_LISTEN_FDS_START (typically fd 3). Your program must use accept() on this pre-existing socket rather than creating its own listener. This pattern is compatible with systemd's socket activation protocol.
Managing Jobs with launchctl
The launchctl command-line tool is your primary interface for loading, unloading, and inspecting Launchd jobs. The modern syntax (macOS 10.10+) uses subcommands.
Loading and Unloading
To load a plist into your user domain:
launchctl load ~/Library/LaunchAgents/com.example.sync-agent.plist
To unload it:
launchctl unload ~/Library/LaunchAgents/com.example.sync-agent.plist
For system daemons, you need sudo:
sudo launchctl load /Library/LaunchDaemons/com.example.mydaemon.plist
sudo launchctl unload /Library/LaunchDaemons/com.example.mydaemon.plist
If your plist has errors or you want to force a reload, use the -w flag to persist the enable/disable state, and -F to force load even if the job is already loaded:
launchctl load -w ~/Library/LaunchAgents/com.example.sync-agent.plist
The Modern bootstrap/bootout Syntax
On recent macOS versions, Apple introduced a newer domain-based syntax. To bootstrap a service into a specific domain:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.sync-agent.plist
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.example.sync-agent.plist
Here, gui/<uid> refers to the GUI login domain for that user. System daemons use the system domain:
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.mydaemon.plist
sudo launchctl bootout system /Library/LaunchDaemons/com.example.mydaemon.plist
Inspecting and Controlling Jobs
List all jobs in your user domain:
launchctl list
The output shows three columns: PID, last exit status, and label. A PID of - means the job is not currently running. An exit status of 0 means the last run succeeded.
To see details about a specific job:
launchctl print gui/$(id -u)/com.example.sync-agent
This produces a rich dump showing the job's state, properties, last exit code, and more — invaluable for debugging.
To start or stop a job manually:
launchctl kickstart -k gui/$(id -u)/com.example.sync-agent
The -k flag kills any running instance before starting a fresh one. To just stop a running job without restarting:
launchctl kill TERM gui/$(id -u)/com.example.sync-agent
A Complete Real-World Example
Let us build a complete, working example: a system daemon that runs a Node.js health-check server and keeps it alive permanently. This demonstrates a realistic production scenario.
First, create the server script at /opt/healthcheck/server.js:
const http = require('http');
const server = http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }));
} else {
res.writeHead(404);
res.end('Not Found');
}
});
server.listen(8080, '127.0.0.1', () => {
console.log('Health check server listening on 127.0.0.1:8080');
});
Next, create the plist at /Library/LaunchDaemons/com.example.healthcheck.plist:
<?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.healthcheck</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/node</string>
<string>/opt/healthcheck/server.js</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/var/log/healthcheck.log</string>
<key>StandardErrorPath</key>
<string>/var/log/healthcheck.err.log</string>
<key>WorkingDirectory</key>
<string>/opt/healthcheck</string>
<key>EnvironmentVariables</key>
<dict>
<key>NODE_ENV</key>
<string>production</string>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>HardResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
<integer>1024</integer>
</dict>
</dict>
</plist>
Set the correct ownership and permissions, validate the plist, then load it:
sudo chown root:wheel /Library/LaunchDaemons/com.example.healthcheck.plist
sudo chmod 644 /Library/LaunchDaemons/com.example.healthcheck.plist
plutil -lint /Library/LaunchDaemons/com.example.healthcheck.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.healthcheck.plist
Verify it is running:
sudo launchctl print system/com.example.healthcheck
curl http://127.0.0.1:8080/health
If you ever need to stop and remove the service:
sudo launchctl bootout system /Library/LaunchDaemons/com.example.healthcheck.plist
sudo rm /Library/LaunchDaemons/com.example.healthcheck.plist
Best Practices
Always Validate Your Plists
Before loading a plist, run it through plutil -lint. A malformed plist will silently fail to load, and the error messages from Launchd are not always obvious:
plutil -lint ~/Library/LaunchAgents/com.example.sync-agent.plist
Use Absolute Paths Everywhere
Launchd does not invoke a shell and does not inherit your interactive PATH. Every executable path, every script path, and every log path should be absolute. If you rely on tools installed by Homebrew, reference them directly (e.g., /opt/homebrew/bin/python3 on Apple Silicon or /usr/local/bin/python3 on Intel).
Set EnvironmentVariables Explicitly
Because Launchd jobs run in a minimal environment, always set PATH and any other variables your program needs. Do not assume HOME, USER, or LANG will be present, especially for system daemons.
Log to Files and Use the Unified Logging System
Always specify StandardOutPath and StandardErrorPath. Without them, output vanishes. For production services, consider writing structured logs and using os_log (the unified logging system) so your messages appear in Console.app and can be queried with log show.
Design for Idempotency
If your job runs on a schedule or in response to file events, it may fire multiple times in quick succession. Design your scripts to be idempotent — for example, by using file locks or checking for an existing output before processing.
Respect Throttle Intervals
Launchd enforces a minimum 10-second interval between job starts. If your job is triggered by WatchPaths and files change rapidly, Launchd will coalesce triggers. Do not fight this behavior; design your job to handle batches.
Secure Your Plists
For system daemons, the plist must be owned by root:wheel and not be writable by group or world. Launchd will refuse to load insecure plists. For user agents, ensure the file is not world-writable.
Avoid Loading the Same Job Twice
Calling launchctl load on an already-loaded job produces an error. Use launchctl unload first, or switch to the bootstrap/bootout syntax, which handles this more gracefully. A common development workflow is:
launchctl unload ~/Library/LaunchAgents/com.example.sync-agent.plist
# edit the plist...
launchctl load ~/Library/LaunchAgents/com.example.sync-agent.plist
Use SMAppService for Sandboxed Apps
If you are shipping a sandboxed macOS app through the App Store, you cannot use raw plists directly. Instead, use the SMAppService API (available on macOS 13+) to register login items and background services programmatically. This is the modern, sandbox-friendly path for app developers, while raw plists remain the right choice for command-line tools and non-sandboxed services.
Common Pitfalls and Debugging
Even experienced developers hit issues with Launchd. Here are the most common problems and how to diagnose them:
- "Service is disabled" or job does not start: You may have previously run
launchctl unload -w, which writes a disabled override to/var/db/launchd.db/. Uselaunchctl enableto re-enable it. - Job runs from terminal but not from Launchd: Almost always an environment issue. Check
PATH, working directory, and whether your script uses shell features Launchd does not provide. - Exit code 78 or 126: Usually a permissions problem or a missing executable. Verify the binary exists, is executable, and has the correct architecture for your Mac.
- Job keeps restarting in a loop: Your process is crashing immediately. Check
StandardErrorPathfor the actual error, and consider raisingThrottleIntervalwhile debugging. - Schedule does not fire: Remember that
StartCalendarIntervaluses 24-hour time and that weekday 1 is Monday, not Sunday. Omitted keys default to wildcard (every), not zero.
The single most useful debugging command is launchctl print, which shows the full resolved state of a job including all properties, last exit code, and run count. Pair it with log show --predicate 'process == "yourprocess"' --last 1h to see system-level messages.
Conclusion
Launchd is a remarkably capable service manager that handles everything from simple scheduled scripts to resilient production daemons with socket activation and automatic restarts. By mastering plist structure, understanding the distinction between daemons and agents, and learning the launchctl commands for loading and inspecting jobs, you gain precise control over how your software runs on macOS. The key to success is treating Launchd configuration as code: validate plists, use absolute paths, set environment variables explicitly, log to files, and design your programs to be idempotent and resilient. Whether you are automating a personal workflow or deploying a fleet-wide service, Launchd provides the reliable, declarative foundation that macOS expects you to build on.