Sweepfor Mac

Mac maintenance

How to Use launchctl on Mac to Manage Background Services

Use launchctl to list, stop, disable, and load background services on macOS — with practical examples for Sonoma, Sequoia, and Apple Silicon Macs.

10 min read

You quit an app and the menu-bar icon stays. You drag the app to the Trash and three days later it’s still phoning home. The thing keeping it alive is almost always a launchd job — a tiny plist file in ~/Library/LaunchAgents or /Library/LaunchDaemons that tells launchd (the master process on every Mac) to keep this thing running.

launchctl is how you talk to launchd from the Terminal. It’s also one of the more confusing CLIs Apple ships, partly because Apple changed the syntax around macOS Yosemite and the old version still works in some places. Here’s the modern, post-Sonoma version.

What launchd actually does

Launchd is PID 1 on every Mac. When you log in, it reads every .plist file in:

  • /System/Library/LaunchDaemons/ (Apple system-level)
  • /System/Library/LaunchAgents/ (Apple user-level)
  • /Library/LaunchDaemons/ (third-party system-level — needs root)
  • /Library/LaunchAgents/ (third-party user-level)
  • ~/Library/LaunchAgents/ (your user — no root needed)

A daemon runs as root, system-wide. An agent runs as a user, when that user is logged in. Both are launched and managed by the same launchd, just under different “domains.”

The four domains you’ll touch:

  • system — daemons running as root (/Library/LaunchDaemons)
  • gui/<uid> — agents for the GUI session of the user with UID <uid>. Yours is usually gui/501.
  • user/<uid> — agents for the user, GUI or not.
  • pid/<pid> — for sub-processes.

id -u gives you your UID. On most personal Macs it’s 501.

Listing what’s running

launchctl list

That dumps every job in your user/GUI domain. The columns are PID, last exit code, and label. A - in PID means it’s not running right now (a job can be loaded but not active). A non-zero exit code means it crashed or exited with an error last time.

To see system daemons:

sudo launchctl list

Want a specific job?

launchctl list com.spotify.webhelper

That gives you the full plist as launchd has it loaded — paths, environment variables, KeepAlive settings.

For a finer-grained view of what’s actually loaded in a domain:

launchctl print gui/$(id -u)
sudo launchctl print system

print is the post-Yosemite verb and shows much more detail than list — exit reasons, last spawn time, current state. Use it when a job is misbehaving and list isn’t telling you why.

Tip: `launchctl print` is verbose. Pipe it through `grep com.thirdparty` or `less` so you can actually find what you're looking for.

Stopping and starting jobs

Old syntax (still works on most plists):

launchctl stop com.spotify.webhelper
launchctl start com.spotify.webhelper

Modern syntax (preferred for system jobs):

launchctl kickstart -k gui/$(id -u)/com.spotify.webhelper

kickstart -k kills the job and restarts it. Useful when a daemon is hung.

To send a signal to a running job:

launchctl kill SIGTERM gui/$(id -u)/com.spotify.webhelper

You can use any signal name (SIGKILL, SIGHUP, etc.) or its numeric value.

Loading and unloading

This is the bit Apple changed.

Old syntax:

launchctl load ~/Library/LaunchAgents/com.example.thing.plist
launchctl unload ~/Library/LaunchAgents/com.example.thing.plist

This still works in user-level domains, but for system daemons it’s been deprecated. The modern equivalents are bootstrap and bootout:

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.thing.plist
launchctl bootout gui/$(id -u)/com.example.thing

For system daemons:

sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.thing.plist
sudo launchctl bootout system/com.example.thing

bootstrap registers the job and starts it (if RunAtLoad is true). bootout removes it from the domain entirely.

Power users use Sweep tooEven when you know the Terminal commands, Sweep is faster and harder to mess up. Get Sweep free →

Disabling a job permanently

If you bootout a job, launchd will load it again at next login because the plist is still there. To keep it disabled:

launchctl disable gui/$(id -u)/com.spotify.webhelper

That writes a “disabled” flag to launchd’s override database. The job won’t load until you enable it:

launchctl enable gui/$(id -u)/com.spotify.webhelper

For system daemons:

sudo launchctl disable system/com.example.daemon

Disabled state persists across reboots. This is the right way to stop a third-party agent from coming back, short of deleting its plist.

Removing a third-party agent for good

The clean uninstall sequence:

# 1. Bootout the job
launchctl bootout gui/$(id -u)/com.spotify.webhelper

# 2. Remove the plist
rm ~/Library/LaunchAgents/com.spotify.webhelper.plist

# 3. (Optional) verify
launchctl list | grep -i spotify

If the agent is in /Library/LaunchAgents (system-wide but user-context), the plist removal needs sudo.

For daemons:

sudo launchctl bootout system/com.adobe.acc.installer.v2
sudo rm /Library/LaunchDaemons/com.adobe.acc.installer.v2.plist

Always check /Library/LaunchAgents, /Library/LaunchDaemons, and ~/Library/LaunchAgents when uninstalling something — Adobe, Microsoft, and Google all spread their plists across multiple locations.

Finding the noisy ones

If your fans have been spinning up and you suspect a runaway daemon:

launchctl list | sort -k2 -n -r | head -20

That sorts by exit code, descending. A daemon that’s been crashlooping (high non-zero exit code) bubbles to the top.

For a more complete view:

launchctl print system | grep -A1 "state = running"

Shows every running system service.

Reading a plist

Plists are XML or binary. Read them as text with plutil:

plutil -p /Library/LaunchAgents/com.adobe.GC.Invoker-1.0.plist

Key fields to understand:

  • Label — the unique identifier; matches the filename (without .plist)
  • ProgramArguments — the command and its args
  • RunAtLoad — start when the plist is loaded (i.e. at login)
  • KeepAlive — restart if it exits
  • StartInterval — run every N seconds
  • StartCalendarInterval — run on a schedule (cron-like)
  • WatchPaths — run when these paths change

KeepAlive is the one that makes things “come back.” If you kill the process and it respawns immediately, that’s KeepAlive: true.

Writing your own LaunchAgent

Here’s a minimal agent that runs a script every 30 minutes when you’re logged in:

<?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.you.cleanup</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Users/you/bin/cleanup.sh</string>
    </array>
    <key>StartInterval</key>
    <integer>1800</integer>
    <key>StandardOutPath</key>
    <string>/tmp/cleanup.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/cleanup.err</string>
</dict>
</plist>

Save it as ~/Library/LaunchAgents/com.you.cleanup.plist, then:

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.cleanup.plist

It’ll start running every 30 minutes. To run on a calendar schedule instead, swap StartInterval for StartCalendarInterval with a dict of Hour, Minute, Weekday, etc.

Skip the Terminal stackSweep does the same cleanup with a UI and a preview before anything is removed. Download Sweep free →

Apple Silicon vs. Intel quirks

The launchctl interface is identical across architectures, but a few gotchas:

  • On Apple Silicon, daemons that need arch -x86_64 to run via Rosetta need that explicit prefix in ProgramArguments. Old Intel-only plists copied from a backup will silently fail.
  • launchctl asuser <uid> (which lets a daemon run a command in a user’s GUI session) became stricter around macOS 13. If you’re seeing “Operation not permitted,” the calling daemon may need Full Disk Access.
  • launchctl submit is officially deprecated. Apple’s docs still mention it, but new code should use bootstrap with a plist.

When launchctl bootout says “No such process”

You’re probably trying to bootout a job that isn’t loaded. To see what’s actually in your domain:

launchctl print gui/$(id -u) | grep -A1 services

If the label isn’t there, the job isn’t loaded — you only need to remove the plist file.

When a job won’t stop running

You bootout, you delete the plist, you reboot — and it’s still there. Three usual culprits:

  1. The app reinstalls its plist on launch. Some apps (Adobe Creative Cloud, certain Microsoft tools) check for their LaunchAgent at startup and recreate it if it’s missing. Solution: uninstall the app properly, or use launchctl disable so the plist stays disabled even when the app rewrites it.
  2. There’s a duplicate in /Library/LaunchAgents. Check both system and user paths.
  3. It’s bound to a system daemon you didn’t notice. sudo launchctl print system | grep -i <name>.

Inventorying everything

A handy one-liner to see every third-party agent on your Mac:

ls /Library/LaunchAgents /Library/LaunchDaemons ~/Library/LaunchAgents 2>/dev/null

Half the entries will be vendors you forgot you ever installed. Auto-updaters, telemetry agents, “helper” apps for printers you sold three years ago. Each one is a process that boots when your Mac boots.

There’s a GUI for thatSweep wraps the Terminal cleanup in a UI you don’t have to memorize. Try Sweep free →

A reasonable approach

Don’t go nuking every plist you don’t recognize — a few are necessary (CrashReporter, Spotlight indexer, mDNSResponder, anything in /System you can’t touch anyway thanks to SIP). The ones to focus on are vendor agents from apps you’ve uninstalled, auto-updaters from apps you don’t use, and the half-dozen “helper” plists Adobe loves.

Once you’ve cleaned them up, your login will be measurably faster and your idle CPU will be lower. launchctl list | wc -l is a satisfying number to watch shrink.

← Back to all guides