← All problems

Mobile Core Concepts

IPC

Why an Android app with more than one process or a cross-app surface needs the IPC primitives, and which one each system-design shape reaches for.

Free~21 min

Every non-trivial Android design eventually crosses a process boundary. Your app calls a system service. Your foreground service exchanges state with the rest of the app over a separate process. A camera intent hands back a content:// URI that points into another app's filesystem. A push notification deep-links into a specific chat. All of these are inter-process communication, and Android exposes them through a small family of abstractions on top of one substrate: Binder. This page covers the substrate, the four high-level IPC mechanisms (Bound Services + AIDL, Messenger, ContentProvider, BroadcastReceiver), and the routing layer (deep links + App Links). Background scheduling and WorkManager live in their own Core Concept; this entry treats them as cross-links.

The IPC landscape

Binder is the kernel driver. Everything else is a higher-level shape on top of it: a typed RPC contract (AIDL), a one-way message queue (Messenger), a URI-addressed CRUD surface (ContentProvider), or a publish-subscribe bus (BroadcastReceiver). Picking the right one is mostly a question of "what does the other side look like, and how chatty is the protocol."

If you wantUseWhy
Typed RPC into another process (your service, another app's service)AIDL over a Bound ServiceCodegen handles marshalling; supports return values and oneway calls
Just a singleton in your own processLocal-only Service or plain DINo IPC needed; binding has no cross-process cost
One-way event stream between two processesMessenger over a Bound ServiceSimpler than AIDL; serialized through one Handler
Share structured data with another app (rows, files, media)ContentProvider (or FileProvider)URI-addressed, permission-scoped, the system uses it
Notify the system or other apps of an eventBroadcast (with strong caveats on API 26+)Pub-sub; mostly registered at runtime now
Hand a URL or notification to a specific screen in your appDeep link + App LinksRouting on top of Intent, verified domain ownership

The same Binder kernel transaction underlies every row in that table. The abstractions exist because raw Binder is one of the most error-prone APIs Android ships - hand-marshalling parcels, managing the reply thread pool, and handling client deaths is exactly the work AIDL and friends automate.

Binder, the substrate

Binder is a kernel driver (/dev/binder) plus a userspace library. A cross-process call is a transaction: the caller's thread serializes arguments into a Parcel, the kernel copies the parcel across the process boundary into a thread on the callee side, the callee runs the method, the kernel copies the reply back, and the caller's thread resumes. Every cross-process call blocks the calling thread until the reply lands - even oneway calls block briefly on the kernel handoff.

Rendering diagram…

Three numbers shape every design that talks Binder:

  • 1 MB transaction buffer per process - shared across all in-flight transactions, not per call. Exceeding it throws TransactionTooLargeException. Sending a large bitmap across Binder is the canonical way to hit this; the right shape is to write the bytes to a FileProvider URI and pass the URI instead.
  • 16 Binder threads per process by default - the pool that services incoming transactions. Long-running work on a Binder thread starves the pool; offload to a coroutine or executor.
  • Binder.getCallingUid() / getCallingPid() - the only trustworthy identity check on the callee side. Anything the caller says about itself in the parcel is forgeable.

Every system service - ActivityManager, PackageManager, WindowManager, ContentResolver, AudioService - is reached through Binder. context.getSystemService(X) returns a proxy whose methods are Binder calls. That's why expensive system-service queries (PackageManager.getPackageInfo() in a loop, repeated getRunningTasks() polls) show up in Perfetto as [waiting on Binder] and why caching their results is a standard perf move. Platform Knowledge covers the runtime side of this; the takeaway here is that Binder isn't an esoteric API you opt into - you're already on it.

Sources: Binder overview (AOSP)

Bound Services

A Bound Service is the canonical server in Android IPC. A client calls bindService(intent, conn, BIND_AUTO_CREATE) and Android brings the service up, calls its onBind(), hands the returned IBinder back through ServiceConnection.onServiceConnected(). The service lives as long as at least one client is bound; when the last client unbinds, onUnbind() and onDestroy() run.

private val conn = object : ServiceConnection {
    override fun onServiceConnected(name: ComponentName, binder: IBinder) {
        playback = IPlaybackService.Stub.asInterface(binder)
        playback?.play(trackId)
    }
    override fun onServiceDisconnected(name: ComponentName) { playback = null }
}
 
context.bindService(Intent(context, PlaybackService::class.java), conn, BIND_AUTO_CREATE)

onServiceDisconnected fires only on unexpected death (service process crash) - not on normal unbind. That's the only place to detect the service's process going away; if you only listen for normal lifecycle callbacks you'll leak a stale proxy.

Two non-obvious patterns:

  • Local-only services - if both ends are in the same process, you don't need IPC at all. Return a Binder subclass that just exposes the service instance directly; clients cast and call methods in-process. The pattern is officially documented and shows up whenever someone wants "a singleton tied to a component lifecycle without the singleton pattern."
  • Started + bound combination - calling startService() and bindService() lets the service outlive the client's binding (started service stays up until stopSelf()) while still giving the client a typed handle. Foreground media playback uses this shape.

Sources: Bound services

AIDL

AIDL (Android Interface Definition Language) is the IDL the Android build tools use to generate a Binder client stub and server skeleton from a single interface declaration. You write a .aidl file; the build produces a Java interface with a nested Stub (server side) and Stub.Proxy (client side). The service implements Stub; the client calls Stub.asInterface(binder) and invokes methods that look local but are Binder transactions under the hood.

// IPlaybackService.aidl
package com.example.playback;
import com.example.playback.IPlaybackCallback;
 
interface IPlaybackService {
    void play(String trackId);                       // blocks caller until reply
    oneway void seek(long positionMs);               // returns immediately
    void registerCallback(IPlaybackCallback cb);
}

oneway methods don't wait for a reply - the kernel queues the transaction on the callee and resumes the caller. They have no return value and can't throw across the boundary; exceptions on the callee side are silently dropped. Use oneway for fire-and-forget notifications (seek, setVolume, telemetry events) and regular methods when you need the result or want failures to surface.

The codegen also handles Parcelable arguments, AIDL-described callbacks (your interface can take another .aidl type as a parameter for server-to-client notifications), and the Stub's thread-safety contract: by default every method runs on a Binder thread, not the main thread, so the implementation must be thread-safe.

When to reach for AIDL: cross-process RPC with a typed contract that has return values, callbacks, or more than one method. When not to: same-process work (local Binder), one-way event streams (Messenger), or any shape that fits a URI-CRUD model (ContentProvider). If you find yourself writing one AIDL method named sendMessage(Bundle), you've reinvented Messenger badly.

Messenger

Messenger is the simpler Binder-backed IPC primitive: one-way Message passing serialized through a Handler. The server creates a Messenger(handler) and exposes its binder from onBind(); the client gets the binder back, wraps it in its own Messenger, and calls .send(msg). Every message lands on the server's Handler in arrival order.

class EventService : Service() {
    private val handler = object : Handler(Looper.getMainLooper()) {
        override fun handleMessage(msg: Message) {
            when (msg.what) {
                EVT_LOG -> log(msg.data.getString("payload"))
            }
        }
    }
    private val messenger = Messenger(handler)
    override fun onBind(intent: Intent): IBinder = messenger.binder
}

The trade-off vs AIDL: no codegen, no Parcelable typing, no return values - you serialize state into Message.what + Message.data (a Bundle) and the receiver dispatches in a when block. The win: no .aidl files, no Stub, no thread-safety concerns (everything runs serialized on the Handler's Looper). Appropriate for unidirectional event streams between processes - "telemetry from a metrics SDK process to the host app," "command channel from a remote view host" - and badly mismatched for anything that wants a reply or a typed multi-method contract.

For server-to-client callbacks, the client passes its own Messenger as Message.replyTo; the server stores it and sends back through it. That's the bidirectional shape, and at that point you should ask whether AIDL with an IInterface callback would be cleaner.

ContentProvider

A ContentProvider exposes structured data under a content://authority/path URI, with a CRUD API: query, insert, update, delete, openFile. The client never talks to your provider class directly - they go through ContentResolver, which Binder-calls into the provider on its hosting process. Contacts, MediaStore, Calendar, Telephony, Downloads, and the system clipboard all expose data as ContentProviders.

// Read every image the user has, sorted newest first.
val cursor = contentResolver.query(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.DATE_ADDED),
    /* selection = */ null, /* selectionArgs = */ null,
    /* sortOrder = */ "${MediaStore.Images.Media.DATE_ADDED} DESC",
)

The reasons to ship a ContentProvider of your own have narrowed over the years - other apps reading your DB is a security smell, and in-process consumers should use Room directly. The two that remain:

  • Cross-app data sharing with a permission model. If another app needs read access to some of your data (contacts plugin reading account info, share extension pulling drafts), a ContentProvider with <grant-uri-permission> and explicit permission attributes is the supported shape.
  • FileProvider - the subclass everyone actually ships. It generates a content://<auth>/path URI for a file under your app's storage, hands it to another app via Intent.EXTRA_STREAM with FLAG_GRANT_READ_URI_PERMISSION, and the receiving app can read the bytes for the lifetime of the grant. Direct file:// URIs throw FileUriExposedException on API 24+ - the OS forces you through a ContentProvider.
val uri = FileProvider.getUriForFile(
    context,
    "${BuildConfig.APPLICATION_ID}.fileprovider",
    File(context.filesDir, "export.pdf"),
)
val intent = Intent(Intent.ACTION_SEND)
    .setType("application/pdf")
    .putExtra(Intent.EXTRA_STREAM, uri)
    .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(Intent.createChooser(intent, null))

The cross-link to Security and Crypto covers why file:// URIs are blocked - they leak filesystem paths and bypass the permission model. The pattern to know cold is FileProvider.getUriForFile plus FLAG_GRANT_READ_URI_PERMISSION on the outbound intent.

BroadcastReceiver

Broadcasts are Android's pub-sub bus: an Intent (ACTION_BOOT_COMPLETED, ACTION_BATTERY_LOW, your own com.example.NEW_MESSAGE) gets dispatched to every receiver that has registered for the matching action. Two flavors plus two registration modes:

  • Implicit broadcast - the action is system-wide; any app with a matching <intent-filter> can receive it.
  • Explicit broadcast - the intent targets a specific component (Intent.setComponent(...)); only that receiver gets it.
  • Manifest-registered - declared in AndroidManifest.xml; the OS can wake your process to deliver.
  • Runtime-registered - context.registerReceiver(receiver, filter) from a live component; only delivered while registered.

The single most important rule on modern Android (API 26+, Oreo): manifest-registered receivers for implicit broadcasts are banned, with a short exempt list (boot completed, locale changed, package replaced, a handful of others). The reason is that implicit-broadcast wakeups were the dominant cause of background battery drain - any of a thousand apps could be woken by a CONNECTIVITY_CHANGE. If you want to react to an implicit broadcast on modern Android, register at runtime from a foreground component, or schedule a WorkManager job with the equivalent constraint.

Two more sub-distinctions worth knowing:

  • Ordered vs normal broadcasts. Normal broadcasts dispatch to all receivers in parallel; ordered broadcasts (sendOrderedBroadcast) run them sequentially in priority order, and any receiver can call abortBroadcast() to stop the chain. Mostly used by the system; rarely the right tool in app code.
  • LocalBroadcastManager is deprecated. It was the in-process pub-sub variant of broadcasts. Replace with a SharedFlow on a process-singleton object (or DI-injected event bus). In-process events should never round-trip through Binder, which is what LocalBroadcastManager effectively did.
// Runtime-registered, scoped to a Lifecycle.
val receiver = object : BroadcastReceiver() {
    override fun onReceive(c: Context, intent: Intent) { refreshSignal() }
}
ContextCompat.registerReceiver(
    context, receiver,
    IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION),
    ContextCompat.RECEIVER_NOT_EXPORTED,
)
// Don't forget context.unregisterReceiver(receiver) on teardown.

The RECEIVER_NOT_EXPORTED flag (required on API 34+ for non-system actions) tells the OS that only your own app can broadcast to this receiver - the default of "any app can fire this" is the wrong default for almost every app-internal action.

A deep link is an Intent that targets a specific destination inside an app. The Android primitive is an <intent-filter> with an android:scheme and android:host matched against the intent's data URI:

<activity android:name=".ChatActivity">
  <intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW"/>
    <category android:name="android.intent.category.DEFAULT"/>
    <category android:name="android.intent.category.BROWSABLE"/>
    <data android:scheme="https" android:host="chat.example.com" android:pathPrefix="/c/"/>
  </intent-filter>
</activity>

A plain https intent-filter is unverified: when the user taps https://chat.example.com/c/42, Android shows a disambiguation dialog ("open with Chrome or Example Chat?") - bad UX, and the user often picks the browser. Android App Links is the extension that bypasses the dialog by verifying domain ownership. Two pieces:

  1. android:autoVerify="true" on the <intent-filter> tells the OS "I claim this domain."
  2. The domain serves https://chat.example.com/.well-known/assetlinks.json listing the package name and SHA-256 of the signing cert.

At install time (and on every package update) the OS fetches the assetlinks file. If the fingerprint matches, the app is the default handler for that domain - taps go straight in. If verification fails the link silently falls back to disambiguation behavior, which is the failure mode interview candidates routinely miss: a misconfigured assetlinks.json doesn't crash, it just degrades. Because it degrades silently, verify it explicitly with adb shell pm get-app-links com.example.app after install rather than trusting the link to open correctly.

Custom schemes (myapp://chat/42) are simpler but lose the open-from-browser story and can't be App-Links-verified - any app can claim myapp://. The modern default is https-only intent filters with autoVerify, falling back to the disambiguation dialog if verification ever breaks.

Sources: Intents and intent filters · Verify Android App Links

See also

  • Platform Knowledge - Binder is briefly framed there as the substrate for system services; FileProvider and the FileUriExposedException rule are introduced. This page expands on the IPC mechanisms layered on top.
  • Concurrency - every cross-process call blocks; suspend functions don't change that. Wrap Binder calls in withContext(Dispatchers.IO) and don't block the main thread on IPlaybackService.play().
  • Security and Crypto - URI permissions, FileProvider permission grants, and why file:// is blocked.
  • Background Work and Scheduling - "should this be a Service or a WorkManager job" comes up constantly; the answer is almost always WorkManager unless you're doing IPC, where a Bound Service is the right shape.

Used in

  • Messenger App - notification deep links route into a specific chat via https:// App Links with autoVerify; the foreground service that holds the WebSocket exposes its connection state via a local Binder.
  • Photo Gallery App - the share-sheet path uses FileProvider plus FLAG_GRANT_READ_URI_PERMISSION; the upload's foreground service binds locally for UI progress.
  • Ride-Sharing App - the location foreground service exposes state to the in-app navigation surface via a local Binder; cross-app handoff (Maps, payment) uses explicit deep links.
  • Doc Editor App - the rendering / formatting service may run in a separate process for memory isolation; the IPC contract between the UI process and renderer is AIDL with oneway for keystroke events and regular methods for save / load.
  • Analytics SDK - host-app event collection from multiple processes routes through Messenger on a single SDK-hosted Service; cross-app event collection (rare) goes through a permission-scoped ContentProvider.

Done reading? Mark it so it sticks in your dashboard.