What happens when you launch an app on macOS
From double-click to first window: LaunchServices, launchd, posix_spawn, AMFI, dyld, the shared cache, sandbox profile installation, the runloop. Six subsystems in three seconds.
You double-click an app. Three seconds later it's running. In between, six different subsystems coordinate to: find the binary, verify its signature, set up its sandbox, fork a process, replace its address space, load every dylib, run static initializers, and finally call main. This article walks the whole thing.
The actors, in order of appearance:
- Finder (the app you double-clicked from)
- LaunchServices (the framework that resolves "which app handles this") — owned by
lsddaemon - launchd (PID 1)
- AMFI (in the kernel)
- the sandbox kext
- dyld (in the new process's userspace)
Step 1: Finder asks LaunchServices
Finder doesn't exec apps directly. It calls LSOpenApplicationAtURL — a LaunchServices framework call.
LaunchServices is the framework that owns the "what app handles this" decision. It maintains a database (/private/var/folders/.../com.apple.LaunchServices*) mapping bundle IDs to app bundle locations, URL schemes to handler apps, file UTIs to handler apps, etc.
For your double-clicked app:
- Finder hands the app's
.appURL to LaunchServices. - LaunchServices figures out: is this app already running? (If yes, just
NSApplication.activateand skip the whole launch.) - If not running: LaunchServices asks
launchdto spawn it.
The cross-process communication here is XPC — LaunchServices in your Finder process talks to the lsd daemon, which talks to launchd.
Step 2: launchd posix_spawns the app
launchd receives the spawn request. It:
- Looks up the app's bundle info from its plist cache (executable path, info plist contents).
- Constructs a
posix_spawnargument set:- Executable:
Foo.app/Contents/MacOS/Foo - argv: standard for app launch
- envp:
DYLD_*variables filtered out (they're stripped for hardened-runtime apps); user defaults injected; Apple-specific variables added - Spawn attributes: jetsam priority, sandbox profile name, QoS defaults
- Executable:
- Calls
posix_spawn(2).
See the fork/exec article for what posix_spawn does internally. The short version: it atomically forks and execs in one syscall, with no broken intermediate state.
Step 3: kernel does exec validation
Now we're in the kernel's exec_mach_imgact. Before the new process runs a single instruction, the kernel:
- Opens the binary, validates the Mach-O headers.
- Validates the code signature: parses the
LC_CODE_SIGNATURE, walks the cert chain to Apple's root, verifies CodeDirectory hashes are intact. See the code signing chain article. - Asks AMFI (
AppleMobileFileIntegrity.kext): is this binary allowed to load? AMFI checks team identifier, library validation status, runtime flags. See the AMFI glossary entry. - Loads entitlements from the signature blob into the new process's task struct.
- Builds the initial VM map: maps
__TEXT,__DATA,__LINKEDITsegments per the Mach-O load commands. - Maps the shared cache —
/System/Library/dyld/dyld_shared_cache_*— at its predetermined address. This is a single large file containing every system dylib, pre-linked. Mapping it once gives the process access to UIKit, AppKit, Foundation, Metal, every CoreFoundation type — all without separate dylib opens.
That last point is one of the most important performance details on macOS: every dylib your app uses is already mapped at process start, courtesy of the shared cache.
Step 4: sandbox is applied
If the app has com.apple.security.app-sandbox = true in its Info.plist (every App Store app does), the kernel:
- Constructs the effective sandbox profile from the app's entitlements.
- Compiles the profile bytecode via libsandbox (this work is mostly done at build time; only fine-tuning happens now).
- Hands the bytecode to Sandbox.kext via
sandbox_apply.
From this point on, every syscall the process makes is gated by the sandbox bytecode. See the sandbox profiles article.
Step 5: dyld takes over
The kernel never directly executes a user binary. It executes dyld. The kernel maps dyld into the new process's address space (it lives at a well-known location in the shared cache), sets up auxiliary vector data (argv, envp, the shared cache slide), and starts the process executing at dyld's entry point.
dyld's job:
- Map the main executable's dependencies if not already in the shared cache. (For modern macOS apps, almost everything is in the shared cache; only third-party frameworks need separate mapping.)
- Resolve every symbol the main executable references against the loaded dylibs. The "chained fixups" format in modern Mach-O makes this fast — fixups are pre-computed at link time.
- Run static initializers in dependency order. Every
+ initializeObjective-C method, every C++ static constructor, every Swiftstatic letruns here. - Call
main(orNSApplicationMain).
For a typical app, the dyld stage takes 10-50 ms.
Step 6: app's main runs
Finally, the app's own code starts. For an AppKit app, NSApplicationMain runs:
- Creates the shared
NSApplication. - Loads the main nib/storyboard.
- Sets up the main run loop.
- Sends
applicationDidFinishLaunching:to the app's delegate. - Enters the run loop, processing events from the WindowServer.
The first window draws when the WindowServer's CoreAnimation pipeline produces the initial frame and the display engine pushes it to the panel. From the user's perspective, the app "appeared" — but the heavy lifting (shared cache mapping, sandbox install, signature validation) was all kernel work that finished before main even started.
What surprises newcomers
- The kernel never executes a user binary directly. Every app starts in dyld. Even
/usr/bin/true. - The shared cache is the reason apps launch fast. Mapping every system dylib via one mmap is dramatically cheaper than dlopen-ing each.
- Signature validation is lazy at the page level, eager at the directory level. The CodeDirectory's signature is verified at exec; individual code pages are verified as they fault in.
- LaunchServices is itself a process. Your Finder calls LaunchServices over XPC to ask
lsd; nothing about the launch happens in Finder's address space.
A complete timeline
t = 0 double-click in Finder
t + 5ms LaunchServices resolves bundle, decides to spawn
t + 10ms launchd receives spawn request
t + 15ms launchd builds posix_spawn args, calls into kernel
t + 20ms kernel: validate Mach-O, signature, entitlements
t + 25ms kernel: install sandbox, map shared cache, map binary
t + 30ms kernel: jumps to dyld
t + 50ms dyld: resolves symbols, runs static initializers
t + 60ms app's main runs
t + 200ms first window drawn (depending on app complexity)
The kernel-side work is bounded — maybe 20 ms regardless of how big the app is. dyld scales with how many dylibs you have outside the shared cache. Static initializers and first-paint scale with how much the app itself does.
What to read next
The articles this synthesizes from:
- How fork(), exec(), and posix_spawn work
- Code signing chain of trust
- Sandbox profiles
- How macOS boots — same
posix_spawnmachinery, just from launchd to itself initially
And source:
apple-oss-distributions/xnubsd/kern/kern_exec.cexec_mach_imgact — the kernel side of every app launch.View on GitHub(line —) apple-oss-distributions/launchdlaunchd/src/launchd.cThe historical launchd open-source release.View on GitHub(line —)