Core Concepts
Multi-Process Architecture
Running Android app on multiple processes is possible, but has its costs
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.
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:
- Put the risky parser in another process so its crash does not take down the UI.
- 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
sharedUserIdbelong to an older Android model.sharedUserIdhas 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:
- Mark the connection unavailable.
- Fail or suspend new requests.
- Decide whether the in-flight operation is safe to retry.
- Rebind with backoff rather than creating a tight restart loop.
- 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.
| Cost | Where it comes from | What helps |
|---|---|---|
| Startup | Process creation, runtime and Application initialization, class loading, native libraries | Lazy start, process-specific initialization, app zygote where appropriate |
| Memory | Separate heaps, writable runtime state, DI graphs, caches, threads and IPC buffers | Keep the worker small, avoid duplicate caches, measure every process |
| Latency | Binder scheduling, parceling, copying, context switches and queueing | Coarse operations, async APIs, file descriptors for bulk data |
| Consistency | More than one memory space can hold related state | One owner, versioned durable state, idempotent commands |
| Failure handling | Caller and worker can die independently or mid-call | Death detection, request IDs, retry policy, atomic output |
| Operations | Per-process logs, traces, heap dumps, crash reports and debuggers | Process/request identifiers and cross-process tracing |
| Security | A boundary is only as narrow as its UID, exports and interface | Isolated 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.
- Name the requirement. Write down the authority, crash, restart, or lifecycle property that must differ.
- Challenge the split. Check whether a thread, coroutine, ordinary Service, WorkManager job, or smaller in-process module solves it.
- Minimize the worker. Move one responsibility, not a copy of the entire app graph. Initialize only what that process uses.
- Choose identity deliberately. Use a private process for lifecycle or crash containment, and an isolated service when reduced authority is required.
- Choose state ownership. Decide who commits results and what survives either process dying.
- Design IPC for failure. Keep Binder messages small; add request IDs, cancellation, timeouts, death handling, and idempotent retry.
- Add the manifest boundary. The attribute now records the process identity and component placement chosen in the earlier steps.
- 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
- Android App Template: the single-process default most apps should begin with.
- One Android app. Why so many processes?: examples from fifteen Android apps showing what they run in separate processes and why.
Done reading? Mark it so it sticks in your dashboard.