Android – ViewModels, Rotation and Process Death

August 17, 20268 min readUpdated 10/11/2026

This is the lesson with no counterpart in the iOS track, because it is about a problem iOS does not have in the same form. On Android, two things routinely happen to a running screen that every app has to survive:

  1. A configuration change — the phone rotates, dark mode switches on, the language changes, a foldable opens. The Activity is destroyed and created again from scratch.
  2. Process death — the app goes to the background, the system needs memory, and your whole process is killed. When the customer comes back, Android recreates the screen they left as if nothing happened — in a brand-new process with none of your objects in it.

The first is easy to notice, because you rotate a phone. The second is the one that ships broken, because it never happens on a developer's phone with plenty of free memory. This lesson covers both, and what this app does about each.

Why rotation destroys your Activity

An Activity was designed in an era of XML layouts, and a rotated screen might need a completely different layout file — a layout-land folder. The simplest way to guarantee the right resources was to throw the Activity away and build a new one with the new configuration. Compose has no layout files, but the behaviour remains: every configuration change destroys and recreates the Activity, and with it every composable and everything in remember.

Three things survive it:

  • A ViewModel, which is kept in a store that outlives the Activity instance.
  • rememberSaveable and the Activity's saved-instance state, which are written to a Bundle.
  • Anything outside the Activity — the Application and singletons — because the process is still alive.

It is tempting to treat rotation as an edge case and lock the app to portrait. On modern devices that does not make the problem go away. Foldables change configuration when opened, tablets and Chromebooks resize windows freely, split-screen resizes on every drag of the divider, and from Android 16 large screens ignore an app's orientation lock entirely. Recreation is a normal event several times a session, and an app has to be built for it.

What a ViewModel is for

A ViewModel holds a screen's state and the work that produces it, in an object whose lifetime is the screen's — not the Activity instance's. Rotate the phone and the new Activity is handed the same ViewModel, with its loaded data and its in-flight requests untouched.

Its lifetime is precise. A ViewModel obtained with hiltViewModel() inside a navigation destination belongs to that destination's back-stack entry: it lives while the screen is on the back stack, and is cleared when the screen is popped. Clearing cancels viewModelScope, so every coroutine launched in it stops by itself. The receipt screen's polling loop relies on exactly this:

init {
    viewModelScope.launch { poll() }
}

Leave the receipt and the ViewModel is cleared, its scope cancelled, and the loop's delay throws CancellationException — it stops itself. Rotate the phone while it polls and nothing happens to it at all.

Run-once work, and why it needs a guard

Work started in a ViewModel's init runs once per ViewModel, which is once per screen — not once per rotation. The root ViewModel starts the app's launch sequence that way:

if (authStore.state.value.isRestoringSession) {
    viewModelScope.launch {
        authStore.restoreSession()
        menuStore.reload()
        cartStore.hydrate()
    }
}

The guard is there for the one case where "once per ViewModel" is not "once per process". The stores are singletons and outlive the Activity. If the customer presses Back out of the app, the Activity finishes and its ViewModel is cleared — but the process often stays alive. Reopen the app and a new Activity gets a new ViewModel whose init would run the whole sequence again. Checking whether the session is still being restored makes the work happen once per process.

What a ViewModel must never hold

Because it outlives the Activity, a ViewModel must not hold anything tied to one: an Activity, a View, a Context other than the application's, or an object that keeps one alive. After the first rotation it would be holding a destroyed Activity, which can never be garbage-collected — a memory leak per rotation. The payments lesson is shaped by this rule: Stripe's payment sheet is registered with the Activity, so the ViewModel cannot own it.

Process death

Now the hard one. When your app is in the background, Android may kill its process to reclaim memory, with no callback and no warning. When the customer switches back, the system does not start your app fresh — it restores the task they left: same Activity, same back stack, same screen, with the saved-instance state it kept for you. What it does not restore:

  • Any ViewModel. They lived in the dead process. You get new ones.
  • Any singleton, any field in your Application, any static. All fresh.
  • Anything in plain remember.

So a screen that kept its data only in a ViewModel comes back empty, on the right screen, looking broken. A checkout that kept its form only in a ViewModel comes back blank — after the customer left to find their card in a banking app, which is precisely when process death is most likely.

SavedStateHandle

A ViewModel can opt part of its state into the saved-instance Bundle through a SavedStateHandle, which Hilt injects for free. Checkout keeps its form there:

val form: StateFlow<CheckoutForm> = savedStateHandle.getStateFlow(KEY_FORM, CheckoutForm())
val selectedAddressId: StateFlow<UuidString> =
    savedStateHandle.getStateFlow(KEY_ADDRESS, NEW_ADDRESS_ID)

getStateFlow returns a StateFlow backed by the handle: it starts from the saved value if there is one, and from the default if not. Writing goes through the handle too, so every keystroke is recorded:

fun updateForm(transform: (CheckoutForm) -> CheckoutForm) {
    val updated = transform(form.value)
    savedStateHandle[KEY_FORM] = updated
    // Re-validate as they type, but only once they have tried to submit — red text under a
    // field the customer has not reached yet is nagging, not help.
    if (hasAttemptedSubmit) _state.update { it.copy(fieldErrors = validate(updated)) }
}

For a whole form to fit in a Bundle it must be Parcelable. Writing that by hand is forty lines of serialisation code; the Kotlin parcelize plugin generates it from one annotation:

@Parcelize
data class CheckoutForm(
    val customerName: String = "",
    val email: String = "",
    val phone: String = "",
    val addressLine1: String = "",
    val city: String = "",
    val state: String = "",
    val postalCode: String = "",
) : Parcelable {

The rest of the ViewModel — whether the order was created, whether payment is in progress — stays in an ordinary MutableStateFlow, and is lost on process death. That is deliberate.

SavedStateHandle or rememberSaveable?

Both write to the same Bundle; the difference is who owns the value. If the composable owns it — which section of the menu is showing, which sheet is open — use rememberSaveable next to the UI that reads it. If the ViewModel owns it, because business logic reads it (checkout validates and submits the form), use the handle, so the ViewModel never has to ask the UI what the customer typed. One owner per value, as always.

What deserves to be saved

The Bundle is small — the whole saved state of a task should stay well under a few hundred kilobytes, and exceeding the limit crashes with TransactionTooLargeException. So saved state is a short list of decisions, not a cache:

  • Save what the user typed or chose: form fields, a selected tab, which sheet was open, a scroll position. Losing these is what they notice.
  • Save ids, not objects. The open builder is saved as a product id; the receipt as an order id. The object can be fetched again; a stale copy would be wrong anyway.
  • Do not save what the server owns. Checkout does not save the created order. After process death the honest recovery is to place it again, not to resume a payment for an object the app can no longer vouch for.
  • Do not save secrets. The sign-in sheet keeps the password in plain remember, because the saved state is written to disk.

The back stack is part of the saved state, including each destination's arguments. That is why the receipt screen reads its order id from its SavedStateHandle rather than from a constructor parameter:

val orderId: String = savedStateHandle.toRoute<OrderRoute>().orderId

After process death, the restored back stack hands the new ViewModel the same route, and the polling simply starts again. No extra code — which is the payoff for putting ids in routes.

Proving it works

Process death has to be tested deliberately, because it will not happen to you by accident. Three ways, from quickest to most faithful:

A unit test. Build a ViewModel, write through it, then build a second ViewModel over the same handle — which is exactly what Android does after recreating the process:

fun `the form survives process death through the saved state handle`() = runTest {
    val handle = SavedStateHandle()
    viewModel(handle).updateForm { validForm }

    // A new ViewModel over the same handle is what Android builds after process death.
    val restored = viewModel(handle)
    assertEquals(validForm, restored.form.value)
    assertNotNull(handle.get<CheckoutForm>("checkout.form"))
}

"Don't keep activities" in Developer options destroys every Activity as soon as it leaves the screen. It tests saved state, but the process survives, so singletons do not reset — it is a partial simulation.

Kill the process for real. Put the app in the background, then:

adb shell am kill com.lovemesomecoding.pizza.android.debug   # only kills a BACKGROUND app
# now reopen it from the recents screen

This app was tested exactly that way against the live backend: a drink added to the cart, the app sent home, the process killed, then reopened. It came back on the Menu tab with the Drinks section still selected (rememberSaveable), the cart badge showing one item (re-fetched from the server by id), and the customer still signed in (the token read back from encrypted storage). Everything that was supposed to survive did; everything else was rebuilt.

Configuration changes you can opt out of — and should not

You will find advice to add android:configChanges="orientation|screenSize" to the manifest so the Activity is not recreated on rotation. It works, for rotation. It does nothing for the other configuration changes, and nothing at all for process death — so it hides the class of bug in the case you test and leaves it in the case you do not. Handle recreation properly once, with ViewModels and saved state, and every one of these cases is covered by the same code.

Next

The back stack has come up twice. The next lesson is Navigation Compose: routes as @Serializable classes, a graph per tab, and replacing checkout with the receipt so Back cannot return to a paid order.