Brief

The what: A quick snapshot of the context

I was on-call duty. Support ticket comes in:

"Client keeps losing onboarding progress. Can't finish registration."

Marketing was running a major acquisition campaign that day.

By end of day: 70 tickets. Same issue.

I pulled the screen.

Textbook setup - MVVM + UDF, exactly what Google recommends.

Nothing wrong on first glance.

Ran my standard process restoration test on the Screen.

Process dead. Users stuck.

That's a “Zombie Screen“.

🐞 The Issue: The screen is alive - but the data is gone.

Breakdown

The when: Reverse engineering the cause and the cost

There are two most dangerous events that can happen to a screen: config change and process death.

Hopefully it's not 2015 and we don't use MVP, so the ViewModel survives config change automatically.

But if you miss state restoration - your mistake could cost real $$$.

So here's what happened…

Chip phone. Expensive client.

We had a logistics app.

With 20+ screens complex onboarding flow.

A really tough user journey.

We required a lot of information:

  • document NFC scans

  • bank statements

  • vehicles docs

  • signatures

  • licenses

  • photos

During this flow, the user opens around 10 different apps to collect the data.

Here's the ViewModel holding all of it:

class OnboardingViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(OnboardingUiState())
    val uiState: StateFlow<OnboardingUiState> = _uiState
}

Default ViewModel setup in 2026. Right?

Google recommends this in their architecture guidelines. And use it in their apps.

But here’s a missing piece…

Most of our users had businesses worth millions of dollars. But their managers used the cheapest Chinese phones they could find.

Tecno. Infinix. itel. OPPO.

(Welcome to the African market)

They all share one thing: bad RAM and processors.

After browsing multiple apps at the same time - the system often says goodbye and kills the app.

When the user gets back.

The screen is alive. The data is gone. The client is lost.

ViewModel holds your State. Not stores it.

Everything you think of as "the app" lives inside a process:

Android System
 └── App Process (PID 12345)
      ├── Activities
      ├── ViewModels
      ├── Hilt graph
      ├── Coroutines
      └── etc...

Android splits every process into priority categories:

Foreground  ← safest, killed last
Visible
Service
Cached      ← killed first

The moment the user switches to another app

→ yours drops to Cached.

After multiple app switches, it's the first thing Android removes.

When the user comes back, Android recreates the Activity from the back stack. Looks like a normal return.

But the process is gone. Which means:

Activity    → recreated
ViewModel   → recreated
Hilt graph  → recreated
State       → reset to default OnboardingUiState()

ViewModel does exactly one thing - it hold the State. Not stores it.

It doesn't survive process death. And lets the State to disappear.

The Fix: Use SavedStateHandle to store the UI State.

Guideline

The how: Implementation guideline and recommendations

Step 1 - Make your UiState savable

SavedStateHandle serialises to a Bundle. Your state class needs to implement Parcelable.

Add the Kotlin Parcelize dependency to libs.version.toml:

[plugins]
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }

Apply the plugin in the top level build.gradle:

plugins {
    alias(libs.plugins.kotlin.parcelize) apply false
}

Add the plugin to your feature module build.gradle as well:

plugins {
    alias(libs.plugins.kotlin.parcelize)
}

Than make your UI State parcelable.

@Parcelize
data class OnboardingUiState(
    val name: String = "",
    val email: String = "",
    val licenseNumber: String = ""
    //other fields...
) : Parcelable

One annotation. The compiler generates the rest.

Step 2 - Build State machine

It's a thin wrapper around SavedStateHandle that handles save and restore automatically.

Three things it does:

  • Restores state from SavedStateHandle on init.

  • Writes every state update to SavedStateHandle automatically.

  • Exposes isStateRestored so you know when to skip the initial data load.

Start with the interface:

interface UiStateMachine<UI_STATE : Parcelable> :
    ReadOnlyProperty<Any?, StateFlow<UI_STATE>> {

    val isStateRestored: Boolean

    fun update(block: UI_STATE.() -> UI_STATE)
}

Then the implementation:

class UiStateMachineImpl<UI_STATE : Parcelable>(
    private val savedStateHandle: SavedStateHandle,
    private val key: String,
    fallback: UI_STATE,
) : UiStateMachine<UI_STATE> {

    override val isStateRestored: Boolean
    private val uiState: MutableStateFlow<UI_STATE>

    init {
        val restoredUiState: UI_STATE? = savedStateHandle[key]
        val currentUiState = restoredUiState ?: fallback

        isStateRestored = restoredUiState != null
        uiState = MutableStateFlow(currentUiState)
    }

    override fun getValue(
        thisRef: Any?, 
        property: KProperty<*>
    ): StateFlow<UI_STATE> = uiState

    override fun update(block: UI_STATE.() -> UI_STATE) {
        val newState = uiState.value.block()
        uiState.value = newState
        savedStateHandle[key] = newState
    }
}

And a builder extension to wire it up:

fun <UI_STATE : Parcelable> SavedStateHandle.asUiStateMachine(
    fallback: UI_STATE,
    key: String = "ui_state", 
): UiStateMachine<UI_STATE> = UiStateMachineImpl(
    savedStateHandle = this,
    key = key,
    fallback = fallback,
)

⚠️ I recommend storing it in a shared module like feature:common to make it accessible across the entire project.

⚠️ For heavy screens (e.g. feeds), consider extending the state machine to persist state to a File rather than SavedStateHandle, as it has limited storage capacity.

Step 3 - Replace StateFlow<T>

class OnboardingViewModel(
    savedState: SavedStateHandle
) : ViewModel() {

    private val uiStateMachine: UiStateMachine<OnboardingUiState> =
        savedState.asUiStateMachine(OnboardingUiState())

    val uiState: StateFlow<OnboardingUiState> by uiStateMachine
}

Every state update is now automatically written to SavedStateHandle.

Update state with one call from anywhere in the ViewModel:

fun setUserName(name: String) {
    uiStateMachine.update { copy(name = name) }
}

State updated and persisted in the same operation.

Step 4 - Skip load on restoration

init {
    if (!uiStateMachine.isStateRestored) {
        loadOnboardingConfig()
    }
}

Without this, your init block fires a request on every restoration (process death) - and overwrites the state you just recovered.

isStateRestored tells you exactly when to skip it.

→ No screen resets silently again.

Let the AI agent do it

I reverse-engineered 70 support tickets into this precise agent workflow.

It finds and fixes every “Zombie Screen“ in your codebase before your users do.

  1. copy the promt to Claude (or other agent)

  2. insert the newsletter link into [ISSUE_LINK]

  3. run the workflow

  4. if something missing agent let you know

You get the full codebase fix in 30 seconds:

Use this newsletter as the issue break down and implementation guideline:
👉 [ISSUE_LINK]

⛔ Do not proceed to the next step without explicit user confirmation.

⛔ Make sure user inserted ISSUE_LINK 

⛔ If you can't process ISSUE_LINK ask user to copy-paste newsletter text

⛔ Scan the entire project — do not limit the search to flows or screens mentioned in the newsletter as examples.

⛔ Make sure you have no blocker before writing any code.

⛔ Do not rely on class naming conventions to identify UI state.


🐞 Step 1 — Identify

Gather issue context from the newsletters “Brief” and “Breakdown” sections.

Scan for any `MutableStateFlow` property declared inside a `ViewModel` — regardless of the state class name.

Include ViewModels that inject `SavedStateHandle` but only use it for route or model params, not for persisting UI state.

Present results as a table:  ViewModel name | risk level.

Mark as HIGH risk any ViewModel attached to screens with user input or multi-step flows.


Ask the user to confirm changes before proceeding.


🔎 Step 2 — Locate

Scan the project structure and identify the best place to store UiStateMechine logic.

If it’s a multi-module project - suggest creating feature:common module.

Otherwise ask a user for a desired location.

Before proceeding confirm that UiStateMachine will be accessible to all the ViewModel from that location.


✅ Step 3 — Fix

Follow the “Guideline” section from the link above EXACTLY.

If any blocker is found, ask for clarification before writing any code.

You should have no blockers before making any changes.

Implement each change directly in the codebase.

Pitch

The why: How to get it approved and get recognized

Before you do anything

Don't just forward this issue to your team.

That's the entry-level move.

Seniors turn this into a business case with a problem, numbers, and a solution.

That's where you'll get recognized and promoted.

Spend 10 minutes in your codebase first:

  1. Find the affected screens - search for MutableStateFlow in ViewModels without SavedStateHandle persistence

  2. Pick 2–3 critical flows - onboarding, checkout, registration, multi-step forms - anything where losing state costs a real user

  3. Put a number on it"If our onboarding resets mid-flow, that's roughly X% drop-off on a $Y acquisition campaign"

You don't need exact numbers. A reasonable estimate is enough.

Leverage on AI research

10x engineers don't grep through the codebase manually anymore.

They delegate the research and show up with numbers.

This prompt scans your codebase for the issue above and returns a risk table ready to drop into the message below.

Use this issue as the context for the research:
👉 [INSSUE_LINK]

⛔ Make sure user has inserted ISSUE_LINK.

⛔ If you can't process ISSUE_LINK, ask user to copy-paste the newsletter text.


💰 Find the risk

Scan the codebase for the issue described in the ISSUE_LINK "Brief" and "Breakdown" sections.

For each affected scope identify the business operation it runs.

Present results as a table: reference | scope name | business operation | issue cost (~X [unit] per [day/week]).

The message

This is your script. Send it to whoever needs to approve the fix - team lead, manager, or the whole team chat.

Hey [name],

I found a process death vulnerability across [X] screens I want to flag.

When a user interacts with multiple apps during our most critical flows, they lose all progress after returning to the app. No crash. No log. Just lost state.

I checked our most critical flows. [Flow 1] and [Flow 2] are both affected. Based on [your estimate], the risk is roughly [number] lost users per [operation].

The fix is a one-class implementation and a one-line change per screen. It takes ~4h to fix by leveraging AI.

I'd like to prioritize it before the next [event/campaign] starts.

Happy to walk you through it if helpful.

→ You walk in as the senior engineer who found the problem, quantified it, and came with the solution ready.

That's not just a fix. That's a promotion conversation waiting to happen 😎

Explore

The where: Related resources and topics

State restoration is a small part of the State lifecycle. Learn how to make your State predictable, not just restorable.

Most Android devs implement MVI without knowing what it actually is. Good historical overview - worth reading before creating your next BaseViewModel.

Devs often ignore State design. Just add a new field. Here's how to select the right model.

So here's my question to you this week:

In my experience 9 of 10 devs don't care about state restoration until it hurts. Do you handle it in your app right now? If yes - how are you doing it?

Keep Reading