Brief
The what: A quick snapshot of the context
In 2020 I was building an e-commerce startup selling spare parts for trucks.
We built an MVP and started acquiring our first customers.
Our CEO was obsessed with analytics, so we were tracking any possible metric.
One Monday I got a Slack DM from the CEO:
"We lose about 15% of first-time buyers on the checkout screen. Nobody can tell me why."
So I decided to double-check the codebase:

Everything looked fine:
Initial data loaded
Non-blocking requests
Survives configuration changes
I was following all guidelines, and even handled process death that 99% of devs ignore, but it didn't really save us.
After days of testing and debugging, the issue was identified…

🐞 The Issue: Restored State silently overwritten. The screen progress gone.

Breakdown
The when: Reverse engineering the cause and the cost
Over the years, the Android development industry built a lot of "bad habits".
And one of them is to load the initial data in the ViewModel's init block.
It became such a default choice that nobody thinks about alternatives, or understands the issues it brings to your app architecture.
So, is it a GOOD or BAD idea?
Issue 1 - Tests that fails at random
One of the default test cases you write for each Screen is verifying the loading state.
Often it's not possible to write three different tests for the initial, intermediate, and final loading state due to Coroutine time synchronization issues.
So you verify multiple state updates using Turbine:
@Test
fun `Should hide progress after order details loaded`() = runTest {
val vm = CheckoutViewModel(SavedStateHandle())
vm.uiState.test {
assertFalse(awaitItem().isOrderDetailsLoading) // initial
assertTrue(awaitItem().isOrderDetailsLoading) // started
assertFalse(awaitItem().isOrderDetailsLoading) // finished
}
}In our Screen we don't have a single full screen loader. All the sections have their own loading placeholders, which is a common UX, and load asynchronously.
You run this test - passed.
You run this test five more times - passed.
You run this test 10 times - the tenth failed.
So why does it happen?

As you can see, coroutines don't guarantee execution order, so the other initial requests can emit their State updates before getOrderDetails finishes. As a result, the wrong State gets asserted.
Yeah, you can hack it to pass by making coroutines run sequentially, but this won't represent production behaviour, and the test will fail as soon as the implementation changes, not the VM's behaviour.
→ It is not a flaky test. It is an untestable ViewModel.
Issue 2 - The screen that overrides itself
A returning customer opens checkout.
The previous delivery address preloaded.
This time he needs to deliver spare parts for his trucks to a different warehouse, so he changed the delivery address.
He decided to double-check the order with his drivers, so he put the app in the background.
He confirmed the order, returned to the app, and made the purchase.
But two days later he got the delivery at the wrong address.

As you can see, the restored State got overridden by the default delivery address from the getDeliveryAddress call that launched during the ViewModel's initialization.
Since the address field is present, the purchase is available.
So even though process death was handled gracefully, we still end up with a data race.
→ SavedStateHandle preserves the State. But the VM's init request silently overrides it.
Issue 3 - Customer that never returns
A user was browsing Facebook when he noticed our shiny AD promising a 10% discount on the first purchase.
He got excited, opened the app, and started looking for a new spare part for his truck.
Filled the cart with a lot of stuff, set up the delivery address, and filled in the payment information.
The discounted price looked like a good deal, but he had a doubt at the last moment: "It's a couple of thousand bucks, I need to check other places as well".
So he checked his favorite place for a better deal.
20 minutes of browsing, no better deal existed.
Opened the app to make a purchase - error displayed.
"Slightly better deal doesn't cost the risk of losing money".
Closed the app, never came back.

As you can see, the ViewModel recreation triggered the getReferralDiscount method to recalculate the price, which ended up displaying the error.
99% of devs think: app makes a request → backend responds.
And no one really cares about a bad internet connection, and especially the buyer's psychology.
Even if you handle the error with some retry logic, you can't handle the buyer's behaviour.
And that's the #1 thing you should always care about, especially if your app operates in tier-two markets like the Philippines, India, and African countries, where most of the population doesn't have a good internet connection.
Any error, any additional click, any lost state - it's an additional user action, which always leads to revenue loss.
→ Don't rely on perfect network conditions. Avoid requests as much as you can.

✅ The Fix: Set up ViewModel configurations.

Guideline
The how: Implementation guideline and recommendations
⚠️ It's not possible to solve these issues in MVVM due to architectural limitations, so this guideline focuses on the MVI implementation.
⚠️ This solution requires a deep understanding of a few related topics. Make sure to check their breakdowns as well.
Step 1 - Empty the init block
Delete the requests from the VM's init block.
init {
// no more getOrderDetails() / getDeliveryAddress() / getReferralDiscount()
}Nothing fires on construction anymore. The screen loads nothing - on purpose.
We're taking back control of when work runs.
Step 2 - Put request on a queue
To have any control over the execution, we put Intents on a queue and provide a method to dispatch them.
private val intents = MutableSharedFlow<CheckoutIntent>()
fun setIntent(intent: CheckoutIntent) {
viewModelScope.launch {
intents.emit(intent)
}
}And convert the requests launched from the init block into Intents.
sealed interface CheckoutIntent {
data object UpdateOrderDetails : CheckoutIntent
data object UpdateDeliveryAddress : CheckoutIntent
data object UpdateReferralDiscount : CheckoutIntent
}🧠 Pro tip: use a strict naming convention for clear separation between startup Intents and user intents:
Update + Operation→ startup IntentsOn + Action + Clicked→ user Intents
Step 3 - Introduce ViewModel configurations
Like in the case where we provide the UI State during initialization to avoid violating the OTOS principle and to have control in tests, we need to dispatch startup Intents as well.
To have strict rules for what goes into the VM configs, we set up a contract:
interface MviConfig<STATE, INTENT> {
val initialState: STATE
val startupIntents: Set<INTENT>
}Where initialState - the Screen's default State, and startupIntents - the requests to run on the VM's initialization.
So the configuration for our screen will look like this:
class CheckoutViewModelConfig(
override val initialState: CheckoutUiState = CheckoutUiState(),
override val startupIntents: Set<CheckoutIntent> = setOf(
UpdateOrderDetails,
UpdateDeliveryAddress,
UpdateReferralDiscount,
),
) : MviConfig<CheckoutUiState, CheckoutIntent>🧠 Pro tip: use a strict naming convention for config implementations: Screen+ViewModel+Config
Step 4 - Dispatch Startup Intents
To process our Intents, we put them into the execution queue.
class CheckoutViewModel(
config: CheckoutViewModelConfig,
) : ViewModel() {
init {
config.startupIntents.forEach(::setIntent)
}
}Yeah, we use the init block again, but this time we have full control over what gets executed on the VM's initialization.
Which lets us write deterministic tests, avoiding intermediate State updates:
@Test
fun `Should hide progress after order details loaded`() = runTest {
val vm = CheckoutViewModel(
config = CheckoutViewModelConfig(
startupIntents = setOf(UpdateOrderDetails), // others skipped
),
)
vm.uiState.test {
assertFalse(awaitItem().isOrderDetailsLoading) // initial
assertTrue(awaitItem().isOrderDetailsLoading) // started
assertFalse(awaitItem().isOrderDetailsLoading) // finished
}
}Step 5 - Handle Process Death
Even though we extracted the startup logic from the VM, we still run it during initialization.
To prevent saved State overrides and additional requests, we can use isStateRestored from the uiStateMachine to detect process death.
init {
if (!uiStateMachine.isStateRestored) {
config.startupIntents.forEach(::setIntent)
}
}Now our requests are skipped during State restoration.
Step 6 - Restorable requests
Startup requests can be split into two parts:
One-off requests. They should never be fired again after the data is loaded. For example: displaying product details, loading the default address, getting payment methods, etc.
Restartable requests. They should be fired on every restoration. For example: Flow collections from the DB, socket connections, time-sensitive data like inventory.
Let's say our getOrderDetails needs to run every time to double-check available inventory.
For filtering such requests, we introduce a filter mechanism. It can be implemented using annotations.
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class RestartableTo distinguish Intents during filtering, we implement a predicate:
fun Any?.isIntentRestartable(): Boolean =
this?.javaClass?.isAnnotationPresent(Restartable::class.java) ?: falseThen we annotate the Intents we need to restart. Non-annotated Intents will be skipped.
sealed interface CheckoutIntent {
@Restartable
data object UpdateOrderDetails : CheckoutIntent
data object UpdateDeliveryAddress : CheckoutIntent
data object UpdateReferralDiscount : CheckoutIntent
}And add filtering to our config's initialization:
init {
val startupIntents = if (uiStateMachine.isStateRestored) {
config.startupIntents.filter { it.isIntentRestartable() }
} else {
config.startupIntents
}
startupIntents.forEach(::setIntent)
}Step 7 - Abstract MVI contract
You don't want this plumbing - the queue, the pipeline, the restoration filter - living in every ViewModel. It belongs in a base class, so each screen only implements what's unique to it.
But a proper base class leans on concepts that deserve their own space: how Partial States work, how State reduction turns them into UI State, and how intent ordering keeps updates deterministic.
I covered the full base class, and those concepts, in the previous issue. Start there, then drop this pattern on top.

One question before you close this.
Everything above lived inside MVI. Most production apps don't.
I want to write the next issues for the stack you actually ship, not the one the docs assume.
So tell me what your screens run on:
