Process Death Is Not an Edge Case
Android kills backgrounded processes routinely, restores the back stack, and leaves your ViewModel gone. What survives process death, what doesn't, how to reproduce it in ten seconds with adb, and the rule for deciding what belongs in saved state.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — Android reclaims backgrounded app processes as a matter of course. When the user returns,
the back stack is restored but your ViewModels are not. The symptom isn't a crash; it's an empty
screen, a forgotten cart, or a lost draft. Reproduce it with adb shell am kill <package> while the
app is backgrounded. Anything whose loss would make the user redo work belongs in SavedStateHandle
or rememberSaveable.
What actually happens
When your app goes to the background, it joins a queue. Under memory pressure — which on a mid-range device with a browser and a messaging app open is most of the time — Android terminates the process.
The part that surprises people is what happens next. Android saved a small Bundle describing your
task stack, so when the user taps your icon they land on the screen they left. It looks like the app
was simply resumed. But the process is new:
- Your
Applicationobject is new. - Every ViewModel is gone.
- Every
objectsingleton is re-initialised. - Every in-memory cache is empty.
- Static/companion state is back to defaults.
The screen renders with a restored Bundle and a ViewModel that has never seen a user. That's the
bug.
Why it never reproduces on your desk
Your development device has plenty of RAM, your app is usually in the foreground, and you relaunch from Android Studio — which starts a fresh task rather than restoring one. The exact conditions that trigger process death are the ones your workflow avoids.
Meanwhile the user opens your app, switches to a messaging app, replies to three people, and comes back nine minutes later. That is the single most common way your app is used.
Reproducing it in ten seconds
# 1. Open the app, navigate to a screen with state, enter something.
# 2. Press HOME (background it — do not swipe it away).
adb shell am kill com.your.app
# 3. Tap the app icon (or recents) to return.
am kill mimics what the system does under pressure: the process dies, the task record survives.
This is not the same as swiping the app from recents, which clears the task and gives you a fresh start — a different scenario that hides the bug you're hunting. Developer Options → Don't keep activities is a useful smoke test, but it destroys Activities without killing the process, so ViewModels survive and you'll miss the class of bug that matters most.
What survives
class CheckoutViewModel @Inject constructor(
private val savedState: SavedStateHandle,
) : ViewModel() {
// Survives process death — written to the saved-state Bundle.
var promoCode: String
get() = savedState["promo"] ?: ""
set(value) { savedState["promo"] = value }
// Gone on process death. Fine for derived/refetchable data.
private val priceCache = mutableMapOf<SkuId, Price>()
}
In Compose:
var query by rememberSaveable { mutableStateOf("") } // survives
var expanded by remember { mutableStateOf(false) } // does not
| Survives | Does not |
|---|---|
SavedStateHandle |
ViewModel fields |
rememberSaveable |
remember |
| DataStore / Room / files | in-memory caches |
| Navigation arguments | object singletons |
SavedStateHandle is backed by a Bundle, so it's for small, serialisable values — ids, text
input, a selected tab. It is not a cache. Putting a list of 200 items there risks
TransactionTooLargeException, which is a crash you'll get in production and never locally.
The rule for what to save
If losing it would make the user redo work, save it.
- A half-written message, a filled-in form, a promo code → save it. Losing it is infuriating.
- The selected tab, an expanded section, a search query → save it, it's cheap and the restore feels correct.
- Scroll position within a list → usually handled for you; don't hand-roll it.
- Fetched data, computed prices, image caches → don't save. Refetch. That's what a repository is for, and stale saved data is worse than a spinner.
The distinction is between user input (irreplaceable) and fetched data (reproducible). Save the first, reload the second.
Restoring without a flash of empty state
The naive restore shows a blank screen for a frame while data refetches. Seed the initial UI state from saved state instead:
private val _state = MutableStateFlow(
CheckoutUiState(promoCode = savedState["promo"] ?: "", items = emptyList())
)
init {
viewModelScope.launch { cart.observe().collect { items -> _state.update { it.copy(items = items) } } }
}
The user's typed input is present on the first frame; the refetchable part arrives when it arrives.
Make it part of the definition of done
Process death is cheap to test and nearly free to get right if you think about it while building — and expensive to retrofit across thirty screens later. On teams I've led, "kill the process and come back" sits in the PR checklist next to rotation. It takes ten seconds and catches a category of bug that otherwise reaches users first.