Mobile Computing · Mobile Platforms

Saved State, Process Death, and the Bundle Size Limit

What survives when the platform reclaims an app process, how the saved-state channel is serialised through the system service, and why its size is capped.

The platform reclaims processes without warning, so any state an app wants to survive that reclaim must leave the process before it dies. There are two channels for that: persistent storage, which the app controls and pays for in bytes written, and the saved-state channel, which the system service holds on the app’s behalf. The second is cheaper and faster, and it has a hard size limit whose consequences are worth understanding precisely.

Where saved state lives between processes

When the platform is about to stop an activity, it calls a callback in which the activity may return a structured object describing what should be restored. That object is not kept in the app’s process. It is serialised into a parcel, sent across the binder boundary to the activity manager, and held in the service’s memory keyed by the activity’s task and identity.

This is the crucial structural fact: the state outlives the process because it is no longer in the process. It survives process death, and it survives the activity being destroyed and recreated for a configuration change. It does not survive a device reboot, and it does not survive the user removing the task from the recents list, because in both cases the service discards the record.

The serialisation format is the same one used for inter-process messages, described in Binder IPC: Parcels, Handles, and One-Way Calls. It is a flat byte buffer with offsets, not a general-purpose serialisation format. Integers and strings are written in place; nested structures are written recursively with a length prefix. Reading is symmetric and position-dependent: the reader must consume fields in exactly the order the writer produced them, which is why a mismatch between the writing and reading version of a class corrupts the rest of the buffer rather than producing one wrong field.

Because the buffer is flat and lives in the sender’s memory until it is transferred, large values cost twice. The sender allocates a buffer sized to the encoded data, and the transfer copies it into the receiver. For a state object holding a bitmap or a long list, both costs are proportional to the data, and both are paid on the main thread, during the transition that is already the busiest moment in the app’s life.

The transaction limit

The binder transaction buffer is a per-process allocation with a fixed capacity. A transaction whose parcel exceeds it cannot be sent, and the call fails with a transaction-too-large error rather than blocking or chunking. The saved-state channel inherits this limit because it uses the same transport.

The practical ceiling is below the transaction buffer size, because the parcel also carries the activity token, the result code, and other fields, and because the receiving service holds many such records at once. Platform documentation states the budget as a fraction of a megabyte rather than a byte count, and the number is deliberately conservative: the state is meant to hold identifiers, scroll positions, and small selections, not content.

Exceeding it fails at the worst moment. The exception is raised inside the lifecycle callback, which means it surfaces as a crash during the transition to the background rather than as an error the app can handle and retry. The failure is also not reproducible in a debug session where the app is never actually reclaimed, which is why the limit is a common source of crashes that appear only on memory-constrained devices.

The mitigation is to treat saved state as a set of keys. Store an identifier for the document, the cursor position, and the identifier of the selected item; reload the item itself from storage on restore. A list of a thousand entries in saved state is a design error even when it fits, because it is serialised and deserialised on every configuration change as well.

// Save: identifiers and positions only, never the content they refer to.
state.putString("document-id", document.id());
state.putInt("scroll-y", list.getScrollY());
state.putLong("selected-row", adapter.getItemId(list.getSelectedItemPosition()));

// Restore: reload the content from storage using the saved identifier.
String id = state.getString("document-id");   // null when nothing was saved

The null case in the second block is not an error path. It is the ordinary path after a reboot or a task removal, and the code that follows it must be able to open the document from persistent storage alone.

What restore actually guarantees

The restore path is not symmetric with the save path, and the asymmetry is where most defects live.

The saved object is restored into a new process, which means the classes it references must be loadable by that process’s class loader. A class defined by a dynamically loaded plugin, or by a class loader created at runtime, cannot be reconstructed from the saved bytes, and the restore fails. The platform passes the class loader it used to create the activity, and the app must supply the correct one when reading.

The restored state may be older than the app’s code. A user can be running a version of the app that saves a field the next version removed, and the record is restored after an update. Reading a field that the writer did not write returns a default rather than an error, so the app sees a zero where it expected a value. Versioning the saved format, or reading defensively, is the only defence.

Restore may not happen at all. If the service discarded the record, or if the process was reclaimed after the record was dropped, the activity is created with no saved state. Code must treat the absence of state as a normal case, not an exceptional one, and must be able to rebuild the screen from persistent storage.

On Apple’s platforms the equivalent channel is different in kind. The system preserves a snapshot of the interface and a small archive of the app’s own state, written to disk rather than held in a service, and restores it when the app is relaunched after termination. The archive is written through the same coder machinery used for persistent storage, so the same versioning hazard applies, and the system may discard the archive if the app was terminated for a reason it considers a fault.

The three tiers of state

Distinguishing the tiers is the whole design problem, and the distinctions are about lifetime, not size.

Volatile state lives in memory and is lost on process death. It is appropriate for data that can be recomputed cheaply or reloaded from storage. Caches, decoded images, and the results of a network request all belong here, because recomputing them is cheaper than serialising them.

Saved state lives in the service and survives process death but not reboot. It is appropriate for the user’s position within a screen: which tab, which scroll offset, which item is selected. It must be small enough to fit the transaction limit and cheap enough to serialise on every configuration change.

Persistent state lives in the app’s own storage and survives everything. It is appropriate for anything the user would notice losing: a draft, a completed action, a preference. Writing it is expensive relative to the other tiers, so it should be written at the points where the data becomes meaningful, not on every keystroke.

The failure pattern is a piece of state that is written only to the first tier while being needed at the third. A form whose contents are held in view objects is lost when the process dies during a phone call, and the user returns to an empty form. The fix is to commit the draft to persistent storage as it changes, which is why text editors on mobile platforms write continuously rather than on exit.

Interaction with the lifecycle

The save callback runs before the stop callback, and it runs on the main thread. Work started in the save callback is not awaited; the parcel is serialised when the callback returns, so asynchronous writes launched from it will not be included.

The save callback also runs for configuration changes, which means the state is serialised and deserialised on every rotation. A state object that is expensive to encode therefore makes rotation slow, and the cost is paid twice per rotation: once to encode and once to decode.

The restore callback runs before the start callback, and after the activity’s views have been created. Code that reads restored state before the views exist will find them absent. The ordering is specified and stable, and it is the reason state restoration is usually expressed as a separate object that the activity attaches to rather than as logic inside creation.

The relationship between saved state and the process ranking described in Lifecycle Callbacks and the Process Importance Ranking is one-way. The ranking determines when the save callback runs; the callback cannot influence the ranking. An app cannot ask to be kept alive by saving more state, and an app that saves nothing is reclaimed exactly as readily as one that saves everything.

Edge cases

A task that is restored after the device reboots receives no saved state, because the service’s records are gone. The app must reconstruct from persistent storage, and the platform provides a marker distinguishing a restore after reboot from a restore after reclaim precisely so that apps can choose different behaviour.

A saved record can be restored into a different instance of the app than the one that wrote it. Two tasks of the same app, or two windows on a platform that supports them, each have their own records keyed by identity. Sharing state between them through the saved-state channel does not work, because the channel is per-instance by construction.

An app that is killed by the platform for a policy violation rather than for memory does not have its state saved. The distinction is not exposed to the app, so an app cannot rely on saved state being present after any particular termination; it can only rely on it being absent sometimes.

References

  1. Android Developers — Saved state APIs — Android Developers
  2. Parcel — Android API reference — Android Developers
  3. Apple Developer Documentation — About the app launch sequence — Apple Inc.