Your Flutter app is a process. The system services it depends on — location, camera, sensors, clipboard, package manager, window manager, activity manager — are in other processes. They're all inside system_server, or in dedicated service processes like surfaceflinger or mediaserver.
The kernel enforces process isolation (Post 1). Process A cannot read Process B's memory. The addresses don't overlap, the page tables are separate, the hardware blocks the access. This is the security model. It's also a problem, because your app needs to talk to those services constantly.
On a desktop Linux system, processes communicate through pipes, Unix domain sockets, shared memory, or message queues. Android uses all of those in narrow cases, but for the vast majority of inter-process communication — the kind that happens thousands of times during a normal app session — it uses something custom: Binder.
Binder is not a library. It's not a framework feature. It's a kernel driver — a piece of code running inside the Linux kernel at EL1, with full hardware access. It exists because the standard Linux IPC mechanisms weren't good enough for what Android needed to do.
Why not just use sockets?
This is the first question worth answering, because it explains the design decisions behind Binder.
Unix domain sockets work. Two processes create a socket, connect, and send bytes back and forth. This is how Zygote receives fork requests (Post 2). It's how the adbd daemon communicates with adb on your laptop. For simple command-and-response protocols between two specific processes, sockets are fine.
But Android's service architecture has different requirements:
Caller identity. When your app asks the LocationManager for the device's GPS coordinates, the LocationManager needs to know who's asking. Not just the socket connection — the actual UID of the calling process. With sockets, you can retrieve the peer's credentials via SO_PEERCRED, but it's opt-in, error-prone, and the identity is checked once at connection time rather than per-call. Binder provides the caller's UID and PID on every single transaction, verified by the kernel. The receiving service doesn't have to trust the caller's claim about who it is — the kernel tells the service directly.
Object-oriented RPC. Android services expose interfaces, not byte streams. ILocationManager has methods like requestLocationUpdates() and getLastKnownLocation(). The client calls these methods as if the service were a local object. Under the surface, Binder serialises the method call, sends it to the service's process, deserialises it, calls the real implementation, serialises the return value, and sends it back. This is a remote procedure call (RPC) mechanism, and building it on raw sockets would require reimplementing this pattern for every service.
Reference counting and death notification. Binder objects have reference counts managed by the kernel driver. When a client process dies, the kernel automatically decrements the reference counts on any Binder objects it held. Services can register for death notifications — "tell me when this client process dies so I can clean up its registrations." This is critical for services that maintain per-client state (location listeners, sensor registrations, callback handlers). With sockets, a dead client is just a broken pipe — the service has to manage cleanup manually.
Thread pool management. The Binder driver manages a thread pool in each process for handling incoming calls. When Process A calls a method on a Binder interface in Process B, the driver wakes a thread in B's Binder thread pool to handle the call. The calling thread in A blocks until B responds. This gives you synchronous, blocking RPC that looks like a method call — which is what developers expect when they call getSystemService() and invoke a method on the result.
Security. Every Binder transaction carries the caller's UID and PID, set by the kernel (not the caller). The receiving service can make permission checks against this identity. LocationManager checks whether the caller has ACCESS_FINE_LOCATION permission. PackageManager checks whether the caller is allowed to query other packages. These checks happen per-transaction, and the identity is unforgeable because it comes from the kernel.
None of these are impossible with sockets. All of them are tedious, error-prone, and would result in each service reimplementing the same infrastructure. Binder centralises it in the kernel.
How Binder works
At the implementation level, Binder is a character device driver at /dev/binder. Processes interact with it through ioctl() syscalls — the same kind of syscall the GPU driver uses (Post 3), just with different command codes.
Here's what happens when your Flutter app's Kotlin code calls getSystemService(Context.LOCATION_SERVICE) and then invokes getLastKnownLocation():
Step 1: Get a reference to the service
App process servicemanager system_server
| | |
|--- ioctl(BINDER_WRITE_READ) ->| |
| "give me ILocationManager" | |
| |--- looks up "location" --->|
| | in service registry |
|<-- returns Binder proxy ------| |
| (handle to remote object) |The servicemanager process is Binder's name service. It maintains a registry of system services by name. When your app asks for the location service, servicemanager returns a Binder handle — an integer that identifies the ILocationManager object in system_server. This handle is meaningful only within the Binder driver; it's not a memory address.
The Android framework wraps this handle in a proxy object: LocationManagerProxy. This proxy implements the ILocationManager interface. When you call methods on it, the proxy doesn't execute the method — it packages the call for transmission.
Step 2: Make the remote call
App process kernel (binder driver) system_server
| | |
| ioctl(BINDER_WRITE_READ, | |
| BC_TRANSACTION { | |
| target: handle_42, | |
| code: TRANSACTION_getLastKnown, | |
| data: Parcel {provider="gps"} | |
| }) | |
| | |
| (thread blocks) ------> | |
| |-- copy data to target -->|
| | wake binder thread |
| | |
| | LocationManagerService
| | .getLastKnownLocation()
| | checks UID permission
| | gets cached location
| | |
| |<-- BC_REPLY { |
| | data: Parcel { |
| | lat, lng, acc...}} |
| | |
| <-- (thread wakes) ----- | |
| returns Location object | |Several things happen in this sequence:
Serialisation (Parcelling). The method arguments are written into a Parcel — a flat binary buffer with a specific format. Strings, integers, floats, arrays, and even Binder object references can be parcelled. The Parcel format is not self-describing — both sides must agree on the order and types of the fields. The AIDL (Android Interface Definition Language) compiler generates the marshalling and unmarshalling code, so developers don't write it by hand.
The ioctl. The calling process makes an ioctl(fd, BINDER_WRITE_READ, &bwr) syscall. fd is the process's file descriptor for /dev/binder. bwr is a binder_write_read structure containing the serialised transaction. This is the syscall you see in strace output (Post 3) as ioctl on a Binder fd.
Data transfer. The Binder driver copies the transaction data from the calling process's address space to the target process's address space. On modern Android (since Android 8), this uses a one-copy mechanism: the target process has a region of its address space mapped to the same physical pages that the Binder driver writes into. So the driver writes the data once, and the target process can read it without an additional copy. This is faster than the traditional two-copy approach (user space → kernel → user space) used by pipes and sockets.
Thread wake-up. The driver wakes a thread from the target process's Binder thread pool. Each process that uses Binder has a pool of threads (typically 16) waiting in ioctl(BINDER_WRITE_READ) calls for incoming transactions. The driver picks one and delivers the transaction to it.
Identity injection. Before delivering the transaction, the driver stamps it with the caller's UID and PID. The calling process cannot set these — the kernel reads them from the process's credentials. When LocationManagerService calls Binder.getCallingUid(), it gets the kernel-verified UID of your app.
Reply. The target thread handles the call, writes the result into a reply Parcel, and sends a BC_REPLY through the same ioctl mechanism. The driver copies the reply to the original calling thread, which wakes up and returns the result.
The entire round trip — serialise, ioctl, copy, handle, reply, copy, return — takes roughly 50-200 microseconds for a simple transaction. More data means more copy time. Complex objects mean more serialisation time.
What your Flutter app talks to via Binder
Your Flutter app might not make many explicit Binder calls in your Dart code, but the platform layer and the Flutter engine make them constantly. Here's a non-exhaustive list of system services your app interacts with during a normal session:
ActivityManagerService. Every lifecycle event. When your app comes to the foreground or goes to the background, when the system wants to know if it's still alive, when it needs to trim memory. The onTrimMemory callback that reaches your FlutterActivity started as a Binder transaction from AMS.
WindowManagerService. Surface creation, window layout, insets (status bar height, navigation bar height, keyboard height). When Flutter queries MediaQuery.of(context).padding, the underlying data came from WMS via Binder.
SurfaceFlinger. The compositor. Every frame your app renders is submitted to SurfaceFlinger via Binder. The Surface that Impeller renders into is a Binder-backed shared buffer.
InputManagerService. Touch events. When the user touches the screen, the input system delivers the event to your app's window via Binder. The event then travels through the native view hierarchy to FlutterView, which dispatches it to the Flutter engine.
PackageManagerService. App installation info, permission checks, intent resolution. When a plugin checks if another app is installed or resolves a share intent, it goes through PMS.
ClipboardService. Every Clipboard.getData() and Clipboard.setData() in Dart translates to a platform channel call that hits the clipboard system service via Binder.
SensorService, LocationService, ConnectivityService, AudioService, CameraService — every hardware-related API your plugins call reaches these services via Binder.
If you run strace on your app and filter for Binder ioctls, you'll see a steady stream of transactions even when the app appears idle — because the framework maintains ongoing communication with the system for lifecycle, display, and input events.
Platform channels and Binder
This is the part Flutter developers should pay attention to.
A platform channel call from Dart to Kotlin does not cross a process boundary. It crosses a thread boundary within the same process. The Flutter engine's platform channel mechanism uses a shared-memory message buffer and thread synchronisation to pass data between the Dart UI thread and the Android platform thread. This is fast — microseconds.
But what happens after the Kotlin handler receives the call often involves Binder. Here's the full chain for a common operation — reading the device's battery level via a method channel:
Dart: methodChannel.invokeMethod('getBatteryLevel')
→ Flutter engine: serialize to binary, post to platform thread
→ Kotlin handler: onMethodCall("getBatteryLevel")
→ BatteryManager.getIntProperty(BATTERY_PROPERTY_CAPACITY)
→ IBatteryService.getProperty()
→ Binder IPC to system_server (BatteryService)
→ system_server reads from /sys/class/power_supply/
→ Reply: 73 (percent)
← Binder reply
← returns 73
← result.success(73)
→ Flutter engine: deserialize, deliver to Dart
→ Dart: Future completes with 73The platform channel itself is cheap. The Binder IPC to the system service is the expensive part — the 50-200 microsecond kernel round trip.
This is why the advice "don't call platform channels in a tight loop" isn't really about platform channels. It's about the Binder transactions those channels trigger. A platform channel call that just reads a value from the Kotlin side's memory (no system service interaction) is nearly free. A platform channel call that triggers a Binder transaction to a system service has real, measurable cost.
Batching platform channel calls
If you need five pieces of information from the platform side, making five separate platform channel calls — each triggering its own Binder transaction — is five kernel round trips. A single platform channel call that returns all five values is one Dart-to-Kotlin transition, and potentially one Binder transaction if the Kotlin code can batch the system service queries.
This isn't always possible (different system services, different query semantics), but where it is, the performance difference is measurable. During a frame build, saving 500 microseconds (five Binder round trips avoided) can be the difference between hitting the 16ms frame budget and dropping a frame.
TransactionTooLargeException
This is the Binder error Flutter developers encounter most often, and understanding Binder explains why it exists and why the threshold seems surprisingly low.
The Binder driver imposes a per-process transaction buffer limit: 1MB. This is the total shared buffer for all in-flight Binder transactions for a process — not per transaction, but across all concurrent transactions the process is participating in.
When a transaction's data (or the accumulated data of all concurrent transactions) exceeds this buffer, the driver rejects the transaction and the framework throws TransactionTooLargeException.
The 1MB limit exists because the Binder buffer is mapped into the process's address space at startup and lives there permanently. It's not heap memory — it's a kernel-managed region. Increasing it would consume more of every process's address space and kernel memory, for all processes on the device, whether they need it or not. Android chose 1MB as a reasonable upper bound for well-behaved IPC.
In Flutter, this most commonly hits in two scenarios:
Saving too much state in `onSaveInstanceState`. When your app goes to the background, Android calls onSaveInstanceState to save UI state. This state is sent to ActivityManagerService via Binder. If FlutterActivity (or a plugin) tries to save a large state bundle — a full navigation stack with embedded images, a serialised Dart object graph — it can exceed the buffer. The fix: save minimal state (a route name, a few IDs), and reconstruct the full state from persistent storage when the app restarts.
Large platform channel messages. If you send a large binary payload through a method channel — a decoded image, a large JSON blob, a database result set — the data gets serialised into a platform channel message, which gets delivered via shared memory (not Binder). But some plugin implementations forward large payloads through Binder to system services. Knowing about the 1MB limit helps you diagnose why a plugin crashes when the data gets large.
The exception is frustrating because 1MB seems generous until you realise it's shared across all concurrent transactions. If your app has three Binder calls in flight simultaneously (which happens easily during a busy lifecycle transition), each one is competing for a share of that 1MB buffer.
Binder threads and ANR
Every process that uses Binder has a thread pool (default 16 threads) for handling incoming Binder transactions. When all threads in the pool are busy — handling callbacks, processing system messages — and a new incoming transaction arrives, it has to wait.
If the incoming transaction is a lifecycle event from ActivityManagerService — like "stop this activity" — and it has to wait too long because all Binder threads are busy, system_server detects the timeout and shows the Application Not Responding dialog.
This is one of the more subtle causes of ANR in Flutter apps. The ANR isn't triggered by your Dart code being slow. It's triggered by the Android platform thread (which is also a Binder thread for incoming calls) being blocked — perhaps by a long-running synchronous platform channel handler, or by a plugin that does I/O on the platform thread.
The fix is always the same: don't block the platform thread. If a platform channel handler needs to do slow work, dispatch it to a background thread and return the result asynchronously.
Observing Binder in practice
You can see Binder activity directly:
adb shell ls -la /proc/14823/fd 2>/dev/null | grep binderOr more usefully, watch the Binder transactions in real time:
adb shell strace -p 14823 -e trace=ioctl -f 2>&1 | grep -i binderYou'll see a stream of ioctl(N, BINDER_WRITE_READ, ...) calls. Each one is a Binder transaction — either your app calling a system service or a system service calling your app.
For deeper inspection:
adb shell cat /d/binder/proc/14823This shows the process's Binder state: its thread pool status, pending transactions, and references to other processes' Binder objects. (Requires a debuggable build or root access.)
The systrace / Perfetto tool captures Binder transactions with timing:
adb shell perfetto -o /data/misc/perfetto-traces/trace.pb -t 10s \
-c - <<EOF
buffers { size_kb: 65536 }
data_sources {
config {
name: "linux.ftrace"
ftrace_config {
ftrace_events: "binder/*"
}
}
}
EOFPull the trace and open it in ui.perfetto.dev. You'll see every Binder transaction your app made, the target service, the duration, and the thread that handled it. During a janky scroll, this tells you whether the jank is from Binder calls blocking the platform thread.
AIDL: the interface language
System services don't define their Binder interfaces by hand. They use AIDL — Android Interface Definition Language. An AIDL file looks like a Java interface:
// ILocationManager.aidl
interface ILocationManager {
Location getLastKnownLocation(String provider);
void requestLocationUpdates(
in LocationRequest request,
in ILocationListener listener
);
}The AIDL compiler generates two classes:
`Stub` — the server-side base class. LocationManagerService extends ILocationManager.Stub and implements the methods. The Stub handles incoming Binder transactions: it reads the method code and arguments from the Parcel, calls the appropriate implementation method, writes the return value to a reply Parcel, and sends it back.
`Proxy` — the client-side class. When your app gets a reference to the location service, it gets an ILocationManager.Proxy. When you call getLastKnownLocation("gps"), the Proxy writes the method code and arguments to a Parcel, sends a Binder transaction, waits for the reply, reads the result from the reply Parcel, and returns it.
This proxy/stub pattern is why calling a system service feels like calling a local method. The serialisation, the kernel transition, the thread wake-up, the identity check — all invisible behind the generated proxy.
Flutter plugins that define their own platform interfaces using Pigeon (the Flutter team's code generator) follow a structurally similar pattern: define an interface, generate the serialisation code, use a communication channel. Pigeon generates method channel code instead of Binder code, but the architectural idea — define once, generate the plumbing — is the same.
Binder vs platform channels: the mental model
For Flutter developers, the practical mental model is:
Dart code
│
├─ Platform channel ──→ Kotlin/Swift code (same process, thread boundary only)
│ │
│ ├─ Local computation: FREE (no IPC)
│ │
│ └─ System service call: Binder IPC
│ (process boundary, kernel involvement,
│ 50-200μs per transaction)
│
└─ Everything else Dart does: stays in-process (no Binder)Your Dart code never touches Binder directly. The Dart VM doesn't know it exists. But the platform layer — the Kotlin code that handles your method channel calls, the Android framework code that manages your Activity's lifecycle, the input system that delivers touch events — uses Binder constantly.
When you're debugging performance issues at the platform layer, or when a plugin throws TransactionTooLargeException, or when an ANR trace points to a Binder thread, this is the mechanism underneath. It's the postal service of Android — every package between your app and the rest of the system passes through it, and understanding the delivery mechanism makes the delivery problems diagnosable.
The scope of it
Binder handles an estimated tens of thousands of transactions per second across all processes on a running Android device. Every app launch, every screen rotation, every notification, every sensor reading, every clipboard operation, every permission check — Binder. It's the most heavily used IPC mechanism on any consumer operating system, running on billions of devices.
And it's a kernel driver. A piece of code running at EL1, inside the Linux kernel, implementing a custom protocol that Linux itself doesn't provide. Android needed something that standard Unix IPC couldn't deliver — identity-verified, object-oriented, reference-counted, thread-managed inter-process communication — and built it into the kernel.
The next post looks at memory from the kernel's perspective: how the kernel manages physical RAM across all those processes, what happens when memory gets tight, and the mechanism — the Low Memory Killer — that decides which process dies when something has to give.
This is Post 4 of the Android Under the Surface series. Previous: The Kernel and System Calls. Next: Memory from the Kernel's Perspective.