← Back to DevBytes

macOS Launchd Services

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:

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:

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:

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:

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:

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:

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles