On Android, apps talk to system services through Binder — a kernel driver that provides identity-verified, object-oriented IPC. iOS has a structurally different IPC architecture built on two layers: Mach messages (the kernel primitive) and XPC (the modern framework built on top).
Your Flutter app on iOS talks to system services constantly — location, notifications, Keychain, camera, Bluetooth, clipboard. Every one of these conversations crosses a process boundary. Understanding the mechanism explains the latency characteristics of platform operations and why certain iOS APIs behave the way they do.
Mach ports: the foundation
XNU's Mach layer provides the IPC primitive: ports. A Mach port is a kernel-managed message queue with access control.
Every port has:
- A receive right. Exactly one task (process) holds the receive right. Only this task can read messages from the port. The receive right is exclusive.
- Send rights. Multiple tasks can hold send rights to the same port. A send right lets a task enqueue a message to the port. The kernel manages the queue.
- A message queue. Messages enqueued by senders, dequeued by the receiver. Messages are kernel-managed and can carry data, out-of-line memory, and port rights.
The key concept: port rights can be transferred. When Task A sends a message to Task B, the message can include a send right to a new port. Task B now has a channel to communicate with whatever service that port represents. This capability-passing model is how complex multi-process topologies are built — a bootstrap server (like launchd) gives your process a send right to the location daemon's port, and now your process can send location requests directly.
Mach messages: the wire format
A Mach message has a fixed structure:
┌─────────────────────────────────────┐
│ Message header │
│ - destination port (send right) │
│ - reply port (send right) │
│ - message size │
│ - message ID │
├─────────────────────────────────────┤
│ Message body (optional) │
│ - inline data (small payloads) │
│ - out-of-line data (large payloads,│
│ transferred via VM page mapping) │
│ - port descriptors (transferring │
│ port rights to the receiver) │
└─────────────────────────────────────┘The message is sent with mach_msg() — a Mach trap (syscall) that blocks the sending thread until the message is enqueued (or, for synchronous calls, until a reply arrives on the reply port).
For small messages (up to a few kilobytes), the data is copied into the kernel's message buffer. For large payloads, Mach uses out-of-line (OOL) transfer — the kernel maps the sender's pages into the receiver's address space using copy-on-write. This means transferring a 1MB buffer doesn't copy 1MB; it maps the pages, and only copies them if either side modifies the data. This is the same copy-on-write mechanism that fork() uses for process creation.
The mach_msg() syscall is to iOS what ioctl(BINDER_WRITE_READ) is to Android — the fundamental IPC (Inter-Port Communication) operation. If you could trace all mach_msg() calls in your Flutter app's process, you'd see the steady stream of communication with system services.
XPC: the modern layer
Raw Mach messages are powerful but low-level. Apple's modern IPC (Inter-Process Communication) framework is XPC (Cross-Process Communication), which builds structured, type-safe communication on top of Mach ports.
XPC provides:
Named services. Instead of manually obtaining port rights, you connect to a service by name. launchd maintains a registry of XPC service names and their corresponding Mach ports. Your process asks launchd for a connection to com.apple.locationd and gets back a communication channel (backed by Mach ports).
Structured messages. XPC messages are dictionaries of typed values (strings, numbers, data, arrays, dictionaries, file descriptors, shared memory regions). No manual serialisation — the framework handles it.
Connection management. XPC connections handle reconnection, message queuing, and service lifecycle automatically. If the target service crashes and restarts, the XPC connection can automatically reconnect.
Entitlement checking. The receiving service can check the connecting client's entitlements — capabilities granted to the app by Apple's code signing and provisioning system. This is iOS's equivalent of Android's permission check on Binder calls: the service verifies that the caller is authorised to use the API.
Most system services on iOS are XPC services managed by launchd. When your Flutter plugin calls CLLocationManager.requestLocation(), the location framework (running in your process) sends an XPC message to locationd (a separate daemon process). locationd checks your app's entitlements (does it have the location entitlement?), reads the GPS hardware, and sends the location back via XPC.
What your Flutter app talks to
Like on Android, your Flutter app communicates with many system services during normal operation. The iOS versions:
`locationd` — Location services. GPS, Wi-Fi positioning, cell tower positioning. Every Geolocator or location plugin call goes through XPC to this daemon.
`SpringBoard` (via FrontBoardServices) — App lifecycle, launch, and window management. Lifecycle transitions come through this channel.
`mediaserverd` — Camera, audio, media playback. The camera and audio_players plugins communicate with this daemon.
`notifyd` — Darwin notification centre. System-wide notifications (battery state, connectivity changes) pass through this daemon.
`apsd` — Apple Push Notification service daemon. Manages the persistent connection to Apple's push notification servers. The firebase_messaging plugin's iOS implementation ultimately talks to this daemon.
`securityd` / `secd` — Keychain services. Every flutter_secure_storage read/write goes through XPC to the security daemon, which manages encrypted Keychain access.
`bluetoothd` — Bluetooth. The flutter_blue_plus plugin communicates with this daemon for BLE scanning and connections.
`nsurlsessiond` — Background URL sessions. If your app uses NSURLSession background transfers (some download plugins do), this daemon manages the transfers even when your app is suspended.
`backboardd` — Input event handling. Touch events from the digitiser hardware are processed by backboardd and delivered to your app via Mach ports.
The communication path for a platform operation
Let's trace a concrete operation: reading a value from the Keychain via flutter_secure_storage.
Dart: secureStorage.read(key: 'auth_token')
→ Platform channel message (Dart UI thread → main thread)
→ Swift handler: SecItemCopyMatching(query, &result)
→ Security.framework (in-process)
→ XPC message to secd (security daemon)
→ secd: Mach message to kernel (keychain database access)
→ secd: decrypt data using Secure Enclave key
→ secd: XPC reply with decrypted value
← Security.framework returns
← Swift handler gets the value
← Platform channel reply (main thread → Dart UI thread)
→ Dart Future completes with 'auth_token_value'The path crosses two process boundaries: your app → secd (via XPC/Mach), and secd → kernel (via syscall for database access). The Secure Enclave interaction (for hardware-backed keys) adds another boundary — secd communicates with the Secure Enclave processor through a dedicated hardware channel.
The latency: typically 1-5ms for a Keychain read. Most of the time is in the XPC round trip and the decryption. For comparison, an in-process read from memory is nanoseconds. This is why flutter_secure_storage docs recommend caching frequently-accessed values in memory after the initial read — the IPC cost is real.
iOS IPC vs Android Binder
The conceptual similarities are strong:
| Feature | Android Binder | iOS Mach/XPC |
|---|---|---|
| Kernel mechanism | Binder driver (/dev/binder) | Mach ports (built into kernel) |
| Message format | Parcel (flat binary) | Mach message / XPC dictionary |
| Identity verification | Kernel stamps caller UID/PID | Kernel stamps caller's audit token |
| Service discovery | ServiceManager | launchd |
| Capability checking | Permission checks on UID | Entitlement checks on code signature |
| Large data transfer | One-copy via mmap | OOL (out-of-line) via VM mapping |
| Thread management | Binder thread pool | Dispatch queues on receiver side |
The practical differences:
Mach messages are lower-level than Binder transactions. Binder provides a complete RPC framework (proxy/stub, method dispatch, AIDL). Mach messages are just messages — the RPC pattern (if needed) is built by higher-level frameworks (XPC, or the older NSDistributedObject). XPC messages are dictionaries, not method calls — there's no equivalent of AIDL's interface definition.
Port rights vs Binder references. Binder uses integer handles to refer to remote objects, managed by the driver. Mach uses port rights — actual kernel-managed capabilities that can be transferred between processes. This makes Mach IPC more flexible (port rights can be delegated arbitrarily) but also more complex to manage.
Entitlements vs permissions. Android checks UID-based permissions at runtime — the user grants or revokes them dynamically. iOS checks code-signing entitlements — capabilities declared at build time, embedded in the binary signature, and verified by the kernel. A runtime permission prompt (location, camera) is layered on top of entitlements. If the entitlement is missing, the runtime prompt doesn't even appear.
Performance characteristics
IPC latency on iOS is comparable to Android's Binder:
- Simple XPC round trip (send request, receive reply): ~50-200 microseconds
- Keychain read (XPC + database + decryption): ~1-5 milliseconds
- Location query (XPC to locationd + hardware): ~10-100 milliseconds (depends on GPS state)
- Camera frame delivery (shared memory via Mach OOL): ~1-2 milliseconds per frame
The cost structure is similar to Android: the IPC mechanism itself (Mach message send/receive) is fast, but the work the receiving service does (database queries, hardware interaction, cryptographic operations) dominates the total latency.
For Flutter, the same advice applies as on Android: platform channel calls are cheap (in-process thread crossing), but the system service calls those channel handlers trigger are expensive (IPC + service work). Batch operations where possible, cache results when appropriate, and don't make IPC-triggering calls in tight loops.
Mach ports in crash reports
Mach ports surface in crash reports in a few ways:
Mach exception types. When your app crashes, the crash report shows both a Mach exception and a BSD signal:
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x0000000000000010EXC_BAD_ACCESS is the Mach exception. SIGSEGV is the BSD signal. The Mach exception is caught first by the kernel's exception handling mechanism (via Mach exception ports), then translated to a BSD signal.
Port leaks. Each process has a limit on the number of Mach port rights it can hold (typically 32,000). If a plugin creates XPC connections or Mach ports without releasing them, the process can hit this limit. The symptom is crashes with EXC_GUARD or resource limit errors. This is analogous to file descriptor leaks — ports are kernel resources that must be released.
Watchdog kills. The 0x8badf00d crash code is delivered via the Mach exception mechanism. The system's watchdog sends a Mach exception to your process's exception port, terminating it.
The invisible plumbing
For day-to-day Flutter development on iOS, Mach messages and XPC are invisible — system frameworks wrap them in Objective-C/Swift APIs that look like local function calls. You call CLLocationManager.requestLocation() and get a delegate callback. You call SecItemCopyMatching() and get a return value. The IPC is hidden.
But the IPC is always there. Every interaction with hardware, every access to secure storage, every push notification, every camera frame — crosses a process boundary via Mach messages. The security model depends on this separation: the daemon that handles your Keychain data runs in a different process with different entitlements, so even if your app is compromised, the Keychain daemon's data is protected by process isolation.
Understanding the mechanism makes three things predictable: latency (IPC calls take microseconds to milliseconds, not nanoseconds), failure modes (the daemon might be busy, the connection might drop, the entitlement might be missing), and resource costs (each active XPC connection consumes Mach port rights and kernel memory).
The next post covers the rendering pipeline: Core Animation, Metal, and how your Flutter frames become pixels on the screen.
This is Post 6 of the iOS Under the Surface series. Previous: Grand Central Dispatch and the Thread Model. Next: Core Animation and Metal: The Rendering Pipeline.*