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.
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 usuallygui/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.
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.
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 argsRunAtLoad— start when the plist is loaded (i.e. at login)KeepAlive— restart if it exitsStartInterval— run every N secondsStartCalendarInterval— 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.
Apple Silicon vs. Intel quirks
The launchctl interface is identical across architectures, but a few gotchas:
- On Apple Silicon, daemons that need
arch -x86_64to run via Rosetta need that explicit prefix inProgramArguments. 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 submitis officially deprecated. Apple’s docs still mention it, but new code should usebootstrapwith 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:
- 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 disableso the plist stays disabled even when the app rewrites it. - There’s a duplicate in
/Library/LaunchAgents. Check both system and user paths. - 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.
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.