Core Concepts

Multi-Process Architecture

Running Android app on multiple processes is possible, but has its costs

Free~24 min

Adding another Android process takes one manifest attribute:

<service
    android:name=".DecoderService"
    android:process=":decoder" />

Running an app with more than one process is one manifest attribute away, with that, Android will start that component on another Linux process.

The attribute does not decide which process owns the job, how an image crosses the boundary, or what happens when the decoder disappears after writing half the output. It also says nothing about whether the worker has fewer permissions or whether total memory decreased.

The manifest creates a boundary. The architecture defines everything that crosses it.

First, separate components, threads, and processes

By default, Android puts an app's activities, services, receivers, and providers in one Linux process. They share one runtime, heap, set of static objects, and main thread. Android creates that process when a component needs to run and can remove it later when its work is no longer important enough to keep resident. Android's process overview.

A Service does not change that model. Unless its manifest says otherwise, it runs in the app process and its callbacks run on that process's main thread. A Service tells Android about the lifetime and importance of work. A coroutine or thread decides where the computation runs. A process decides which memory, identity, and failures it shares. Android's Services overview.

Rendering diagram...

The ordinary :decoder process has a separate heap and crash boundary, but it normally runs under the app's UID. The isolated worker has a restricted identity without the app's normal permissions. Those are different architectures.

What can justify the boundary?

A large feature does not require another process by itself. The boundary needs a specific property that the two sides must not share.

We do not trust the work with the app's authority

A browser renderer consumes HTML, JavaScript, fonts, images, and other input chosen by remote sites. A media decoder consumes complex, sometimes malformed files in native code. If either is compromised, we do not want it reading the app's credentials or private database.

An isolated service gives that worker a restricted identity. The caller passes only the capabilities required for one operation, often a file descriptor rather than a filesystem path. A thread cannot provide the same boundary because it cannot remove its process's permissions.

"Sandbox it" contains two separate decisions:

  1. Put the risky parser in another process so its crash does not take down the UI.
  2. Remove the app's authority from that process so a compromise has less reach.

A named same-UID process delivers the first property, not automatically the second.

We expect the work to fail differently

Native memory corruption and segmentation faults kill the process. try/catch cannot contain them. If a fragile codec or plugin runs elsewhere, Android can remove that process while the main UI survives.

This only helps when the surviving process knows how to recover. It needs durable input, an idempotent request, a way to recognize incomplete output, and a user experience for retry or fallback. Without those, we have traded one full-app crash for a feature that silently hangs.

One process must survive or replace another

Restart coordinators are an unusual but useful pattern. The helper receives the main PID and a relaunch intent, terminates the old process, starts the replacement, and then exits itself. Chrome and Discord both package a version of this design.

The helper is not a permanent background architecture. Its reason for existing is narrower: it cannot coordinate the death and replacement of the process while living only inside that process.

A subsystem needs an independent lifetime

An embedded browser, game runtime, camera scripting engine, or model worker may have a natural lifetime different from the feed or navigation UI. Another process lets the app warm, retain, discard, or recover that subsystem independently.

A separate process does not guarantee persistence, and a foreground service does not require one. Android still chooses process lifetime from component state, bindings, user importance, and system pressure. The value is independent lifecycle and reconstruction, not immortality.

A platform or engine already requires it

Some engines already require multiple processes. Chromium is designed around a trusted browser process and less-trusted child processes. Trying to flatten that into one Android process would weaken assumptions far beyond Android component placement.

The One Android app. Why so many processes? shows all of these patterns in Chrome, Meta, WhatsApp, Discord, TikTok, Maps, and three useful single-process counterexamples.

Choose the process identity

There are two common starting points.

<service
    android:name=".RenderService"
    android:exported="false"
    android:process=":renderer" />
 
<service
    android:name=".IsolatedDecoderService"
    android:exported="false"
    android:isolatedProcess="true" />

The : prefix makes :renderer private to the package namespace. Components in it normally retain the application's UID and permissions. It is useful for crash containment, separate lifecycle, or a restart helper, but same UID means it is not a meaningful permissions boundary by itself.

isolatedProcess="true" asks Android to run the service under a special identity without the app's permissions. Communication goes through the Service API. Android's service manifest reference. The worker should receive capabilities for one job, not broad access recreated through exported providers or Binder methods.

Also check android:exported and any component permission. Process placement does not decide who may invoke a component. An exported isolated service with an overly powerful interface can still be a security problem.

App zygote and native services

Repeated isolated startup can be expensive. useAppZygote lets eligible isolated services fork from an application-specific zygote with common initialization already loaded. This can reduce startup work and share clean pages, but the preloaded state must be safe to inherit.

Android 17 adds native services for a narrower case. When a service is isolated and marked as native, Android can load a native library and enter it without initializing ART. The flag is ignored for a non-isolated service. Android's FLAG_NATIVE_SERVICE reference.

These mechanisms make a required boundary cheaper. They are not independent reasons to create one.

Legacy note: global process names and sharedUserId belong to an older Android model. sharedUserId has been discouraged for years and should not be the basis of a new architecture. Use package-private process names and explicit IPC instead.

Keep Binder messages small

Once code moves out of process, an ordinary method call becomes a protocol. AIDL, Messenger, and providers are covered in the IPC concept. The architecture questions come first:

  • Which side owns mutable state?
  • Is the request safe to repeat?
  • Can either side cancel?
  • What does partial success mean?
  • How does the client recognize an old response after reconnecting?
  • Does the payload belong in Binder at all?

Binder's transaction buffer has a fixed, finite size, currently documented as 1 MB per process and shared by transactions in flight. A moderate call can fail when several others occupy the buffer, so 1 MB is not a payload target. Android's TransactionTooLargeException reference.

Use Binder for small commands and metadata. Send large images, video, database snapshots, or model data through a file descriptor, pipe, or shared memory.

An isolated decoder contract might look like this:

interface IImageDecoder {
    DecodeResult decode(
        in ParcelFileDescriptor input,
        in DecodeOptions options
    );
}

The input descriptor grants access to that opened object, not the caller's whole filesystem. The result can contain dimensions, status, and a descriptor for shared-memory-backed pixels. The protocol also needs a request ID, cancellation, an ownership rule for closing descriptors, and a clear answer for incomplete output.

Choose one owner for mutable state

Every process gets its own Application instance, dependency graph, Kotlin object instances, statics, caches, pools, and background executors. Updating a singleton in the UI process changes nothing in :renderer.

Treat those copies as independent. Pick one owner for mutable state and make other processes ask that owner or read a durable representation. A database with an explicit writer, a provider, or a versioned Binder service can serve that role. Plain SharedPreferences and duplicated in-memory caches are not live cross-process coordination.

Durability matters because onDestroy() is not a commit hook. Android can remove a process without calling it. Persist the minimum information needed to identify and resume work before acknowledging the request.

Design for either side to disappear

A bound service often lives while clients remain bound, and Android uses bindings when calculating process importance. If process A depends on a service in process B, B may be treated as at least as important as A. That makes the dependency visible to the system; it does not make B permanent. Android's process lifecycle guide.

Remote calls can throw DeadObjectException. ServiceConnection can report an unexpected disconnection. A Binder death recipient can notify a client that the remote Binder is gone. Treat those as regular protocol states:

  1. Mark the connection unavailable.
  2. Fail or suspend new requests.
  3. Decide whether the in-flight operation is safe to retry.
  4. Rebind with backoff rather than creating a tight restart loop.
  5. Reconcile durable job state before sending work again.

Partial failure needs special handling. If the caller dies after the worker commits but before the reply arrives, retrying may perform the action twice. Request IDs, idempotent writes, atomic output replacement, and durable completion records are more important than a clever reconnection callback.

What does another process cost?

The exact number depends on what each process loads. There is no useful rule such as "three processes cost three times as much." Processes start lazily, perform different initialization, and may share clean mapped pages. But every boundary creates places where cost can appear.

CostWhere it comes fromWhat helps
StartupProcess creation, runtime and Application initialization, class loading, native librariesLazy start, process-specific initialization, app zygote where appropriate
MemorySeparate heaps, writable runtime state, DI graphs, caches, threads and IPC buffersKeep the worker small, avoid duplicate caches, measure every process
LatencyBinder scheduling, parceling, copying, context switches and queueingCoarse operations, async APIs, file descriptors for bulk data
ConsistencyMore than one memory space can hold related stateOne owner, versioned durable state, idempotent commands
Failure handlingCaller and worker can die independently or mid-callDeath detection, request IDs, retry policy, atomic output
OperationsPer-process logs, traces, heap dumps, crash reports and debuggersProcess/request identifiers and cross-process tracing
SecurityA boundary is only as narrow as its UID, exports and interfaceIsolated identity, least-capability IPC, caller validation

The design also has a maintenance cost. Every engineer who touches this feature must remember that initialization and state are per process. That cost remains even when the worker is not running.

Measure memory across every process

A second process can be useful for memory. Android can reclaim or terminate it without necessarily tearing down the UI, and clean mapped pages may be shared. When a decoder exits, its private heap disappears as a unit.

It can also increase total memory. The worker may load another runtime, create another dependency graph, retain another cache, and copy data that used to stay local. Looking only at the main process can therefore show a saving that disappears when we include the worker.

Measure the whole process set. RSS is useful per process but double-counts shared pages when summed. PSS apportions shared pages and is usually a better estimate for an app-level comparison. Keep swap visible, and compare the same workload and lifecycle stages.

Android 17 makes this accounting more important. Its Memory Limiter applies limits and targeted reclaim to individual app processes, with limits selected from the process's current state. A foreground service is classified as not visible for this policy, while a top or bound-top process is visible. AOSP Memory Limiter documentation.

That means an existing process boundary now affects where reclaim and throttling land. Process creation does not reduce aggregate memory by itself. Using multiple processes only to obtain additional limits can raise aggregate cost and leave the app with the harder architecture. First reduce the working set. Add a process when the boundary also has a defensible job or independent failure is itself the requirement.

android:largeHeap does not solve this design question either. It changes the managed-heap class available to the app; it does not remove native, graphics, mapping, or total-process costs.

A practical implementation sequence

Define the contract before adding the manifest attribute.

  1. Name the requirement. Write down the authority, crash, restart, or lifecycle property that must differ.
  2. Challenge the split. Check whether a thread, coroutine, ordinary Service, WorkManager job, or smaller in-process module solves it.
  3. Minimize the worker. Move one responsibility, not a copy of the entire app graph. Initialize only what that process uses.
  4. Choose identity deliberately. Use a private process for lifecycle or crash containment, and an isolated service when reduced authority is required.
  5. Choose state ownership. Decide who commits results and what survives either process dying.
  6. Design IPC for failure. Keep Binder messages small; add request IDs, cancellation, timeouts, death handling, and idempotent retry.
  7. Add the manifest boundary. The attribute now records the process identity and component placement chosen in the earlier steps.
  8. Measure the result. Compare startup, latency, battery, aggregate memory, and recovery against the single-process version.

Test process death and recovery

A second process deserves a test matrix:

  • Confirm the actual PID, process name, UID, permissions, and hosted component.
  • Kill the worker during a request, after output is written, and before the reply.
  • Kill the caller while the worker is active.
  • Start the worker before the main activity and catch hidden initialization dependencies.
  • Exercise rapid bind/unbind and concurrent callers.
  • Verify that retries do not duplicate committed work.
  • Correlate logs and traces with process name and request ID.
  • Profile cold start and memory across every participating PID.
  • Check exported components and Binder methods with an untrusted-caller mindset.

Decision rule

Add a process when a specific responsibility must have different authority, failure, restart, or lifetime semantics, and when that benefit is worth owning a real IPC and recovery protocol.

Otherwise, keep the app in one process. Telegram, Spotify, and ChatGPT all package large Android applications that way. Evaluate a process boundary by the specific authority, failure, restart, or lifecycle requirement it satisfies.

See also

  • IPC: Binder, AIDL, Messenger, providers, and the communication primitives used at the boundary.
  • Process Model and Lifecycles: process importance, cached states, and process death.
  • Memory and GC: Java, native, mappings, graphics, and proportional memory accounting.
  • NDK and JNI: native failure modes and the cases where containment can justify IPC.

Used in

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

Discussion