5 min read

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.

AndroidState ManagementSavedStateHandleLifecycleKotlin
HR

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 Application object is new.
  • Every ViewModel is gone.
  • Every object singleton 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.

Keep reading