Brief
The what: A quick snapshot of the context
May 2023. I joined a new team, rebuilding a bank's app from scratch.
It was the right time to start with Compose, so the team decided to give MVI a shot.
Day one. I opened my laptop.
"Welcome aboard," said the PO, sliding three tickets over.
"[JIRA-001] Fix flaky behaviour on the transaction screen."
A user makes an A2A transaction and cancels it right away. The backend handles it properly, but the app displays the status Payment successful.
I opened the screen and saw the typical "Medium article" setup.

It was a typical race condition...

🐞 The Issue: Intent submission order ignored. The last write wins.

Breakdown
The when: Reverse engineering the cause and the cost
When you pick the architecture for your app, you should always think about the problem you want to solve.
Fortunately, for Android that pick is simple: MVI.
MVVM died the same day as data binding and LiveData. Once devs moved to Jetpack Compose, there were no options left.
The main problem MVI solves is this: predictable UI state changes during asynchronous data processing.
And no one knows how to handle it. They just call MutableStateFlow.update().
Order by luck
In a typical banking app, the last screen in the payment flow is transaction confirmation.
It shows the transaction details and has two buttons: pay and cancel.
"Pay" is self-explanatory - make the payment.
"Cancel" closes the payment screen and cancels the transaction, if it was already initiated. That is our case.
The user wants to pay for a purchase, enters the IBAN and 200$ to send - clicks pay. The OnPayClick intent is submitted:
fun mapIntent(intent: Intent) = when(intent){
OnPayClick -> processPayment()
}
fun processPayment() {
viewModelScope.launch {
paymentRepository.pay(
amount = uiState.amount,
iban = uiState.iban
id = uiState.transationId
)
.onSuccess { uiState.update { copy(isSuccessful = true) } }
.onFailure { uiState.update { copy(isSuccessful = false) } }
}
}Then he realizes the amount was wrong. He accidentally entered one more zero, so the total became 2000$.
So he cancels it instantly by clicking cancel. The OnCancelClick intent is submitted:
fun mapIntent(intent: Intent) = when(intent){
OnCancelClick -> cancelPayment()
}
fun processPayment() {
viewModelScope.launch {
paymentRepository.cancel(uiState.transationId)
.onSuccess { uiState.update { copy(isCanceled = true) } }
}
}Now two asynchronous jobs update the status of the same transaction.
Core banking systems are complex, and requests like payments never process synchronously. The client sends the intent to make a transaction, the backend responds transaction allowed, returns 200, and keeps processing the payment asynchronously.
So when you send pay and cancel, the backend treats them as two unrelated requests, even though they change the same transaction state.
From here it is easy to hit a situation where cancel needs less acknowledgement and finishes faster than pay.
Here is the bigger picture:

→ The requests left in order. The replies came back reversed.
The user sees "Transaction successful."
He panics. 10x the amount, gone.
He files a support ticket.
The bank's reputation takes the hit, and the user's trust is gone.
Even though the backend canceled the transaction.
Atomic is not the same as ordered
MutableStateFlow.update() guarantees atomicity: every write lands intact, and two coroutines writing at once can't corrupt each other.
That fixes the shared mutable state problem.
But execution order is ignored. That leaves a race condition, where the UI state depends entirely on timing.
Here is a closer look at what happens in our ViewModel:

Write order equals completion order, set by the network. Not by the user.
Pay is submitted first but returns last, so it overwrites cancel. The user acted in one order; the state landed in another.
The instinct is to fix it with operators. Turn each one-shot job into a Flow, merge the intents into one stream, flatMap it:
intents.flatMapMerge(transform = ::mapIntent)But no built-in flatMap does both jobs at once. Each trades one requirement for the other:
If the pipeline needs | Operator | Trade-off |
|---|---|---|
Concurrency, order irrelevant |
| Concurrent intents interleave. Order is lost. |
Order, concurrency irrelevant |
| Runs one at a time. A slow intent blocks the rest. |
Newest intent wins |
| Cancels in-flight work. Wrong for independent intents. |
Concurrency and strict order | custom | No built-in operator does this. |
Concurrency wants later work started early. Order wants later results held. Built-in operators pick one side.
And a common mistake in tutorials is using Reducer, a class that does nothing but store a state mapping.
It takes the current state and a new partial and maps them into the next state:
fun reduce(current: State, new: PartialState): State = when (new) {
is PaymentResult -> current.copy(isSuccessful = new.isSuccess)
is CancelResult -> current.copy(isCanceled = new.IsCanceled)
}Pure. Testable. And it sequences nothing.
It still runs inside the racing coroutines, its result still written with update(). A reducer maps state. It does not order it.
One more layer, same bug.
→ update() guards the write. Nothing guards the order.

✅ The Fix: Build an asynchronous FIFO queue.

Guideline
The how: Implementation guideline and recommendations
⚠️ This guideline is focused on intent processing and state updates. One-time side effects and the more complex cases are left out on purpose.
Step 1 - Build the queue
No built-in flatMap gives you concurrency and order at once. So we build one that does: run each transform in parallel, emit the results in source order.
I adapted a very old implementation from Louis Wasserman (a Googler): map-concurrent, building on Roman Elizarov's buffering rule.
It lives in the kotlinx.coroutines GitHub but was never merged. I have run it on multiple production apps, with unit and performance tests. It works like a clock.
Copy it into your project. We will need it later.
Step 2 - Create the Partial State
Intents run asynchronously, and the queue holds their output to keep the order before it touches the screen. So we need a holder for that output.
That holder is the partial state. It carries one result, which the reducer later folds into the screen state.
@Parcelize
data class TransactionScreenUiState(
val isSuccessful: Boolean = false,
val isCanceled: Boolean = false,
val amount: Int = 0,
val iban: String = "",
val transactionId: String = "",
) : Parcelable
sealed interface PartialState {
data class OnPaymentStateChanged(val isSuccessful: Boolean) : PartialState
data class OnCancelationStateChanged(val isCanceled: Boolean) : PartialState
}Step 3 - Set up the Intents flow
Before we can map intents into partial states, we need a stream to hold them.
setIntent submits an intent to the queue. mapIntents handles the mapping in the next step.
private val intents = MutableSharedFlow<Intent>()
fun setIntent(intent: Intent) {
viewModelScope.launch {
intents.emit(intent)
}
}Step 4 - Map Intents to Partial State
Each intent becomes a Flow of partial states, run through the operator so they process concurrently but stay ordered.
Any action, cancelling a payment for example, now returns a flow that emits partial results:
fun cancelPayment(transactionId: String) = flow {
paymentRepository.cancel(transactionId)
.onSuccess { emit(OnCancelationStateChanged(isCanceled = true)) }
}Then map every intent concurrently into partial states:
abstract fun mapIntents(intent: Intent): Flow<PartialState>
init {
intents
.flatMapConcurrently(transform = ::mapIntents)
}mapIntents is abstract. Each ViewModel implements it to wire its own actions, the same way as before.
Step 5 - Reduce in submission order
Once a partial state leaves the queue, a scan operator folds it into the UI state.
Like intent mapping, state reduction is an abstract function each ViewModel overrides as part of the contract.
The pipeline becomes:
abstract fun reduceUiState(
previousState: TransactionScreenUiState,
partialState: PartialState,
): TransactionScreenUiState
init {
intents
.flatMapConcurrently(transform = ::mapIntents)
.scan(initial = uiState.value, operation = ::reduceUiState)
.collect(uiState)
}Here’s a more detailed view on the execution order:

Step 5 - Abstract MVI contract
To keep every ViewModel free of this plumbing, move the whole pipeline into a base class and expose only the abstract functions for implementation.
abstract class MviViewModel
UI_STATE : Parcelable,
UI_PARTIAL_STATE,
INTENT,
>(
savedStateHandle: SavedStateHandle,
initialState: UI_STATE,
) : ViewModel() {
private val intents = MutableSharedFlow<INTENT>()
private val uiStateMachine: UiStateMachine<UI_STATE> =
savedStateHandle.asUiStateMachine(initialState)
val uiState: StateFlow<UI_STATE> by uiStateMachine
init {
viewModelScope.launch {
intents
.flatMapConcurrently(transform = ::mapIntents)
.scan(initial = uiState.value, operation = ::reduceUiState)
.collect(uiStateMachine)
}
}
fun setIntent(intent: INTENT) {
viewModelScope.launch {
intents.emit(intent)
}
}
protected abstract fun mapIntents(intent: INTENT): Flow<UI_PARTIAL_STATE>
protected abstract fun reduceUiState(
previousState: UI_STATE,
partialState: UI_PARTIAL_STATE,
): UI_STATE
}⚠️ Do not use MutableStateFlow to hold the UI state. It does not survive process death. Use the UiStateMachine instead.
⚠️ Do not hardcode the initial UI state. Inject it, so you can test the ViewModel without breaking the OTOA and OTOS principles.
Step 6 - Implement the MVI contract
A real screen is now small. Wire actions in mapIntents, fold results in reduceUiState .
class TransactionViewModel(
savedStateHandle: SavedStateHandle,
initialState: TransactionScreenUiState,
) : MviViewModel<TransactionScreenUiState, PartialState, Intent>(
savedStateHandle = savedStateHandle,
initialState = initialState,
) {
override fun mapIntents(intent: Intent): Flow<PartialState> = when (intent) {
OnPayClick -> processPayment()
OnCancelClick -> cancelPayment()
}
override fun reduceUiState(
previousState: TransactionScreenUiState,
partialState: PartialState,
): TransactionScreenUiState = when (partialState) {
is OnPaymentStateChanged -> previousState.copy(isSuccessful = partialState.isSuccessful)
is OnCancelationStateChanged -> previousState.copy(isCanceled = partialState.isCanceled)
}
}⚠️ Keep reduceUiState pure. A suspend call or a side effect in it puts the timing bug right back.
The payoff:
concurrent work, no frozen UI
partials applied in submission order
one pure reducer, one writer
fixed once in the base class
→ The screen ends on the user's last action. Cancel means Cancel.

Explore
The where: Related resources and topics
The RxJava-era article that first put partial state behind a scan reducer. This is where the pattern that inspired me started.
Missing state restoration is even more critical than having hardcoded UI state. Learn why you should never use MutableStateFlow for holding screen state.
Why the initial state is injected, not hardcoded, and how that keeps the ViewModel testable. Here's how to set it up without breaking OTOA and OTOS.

Here's the truth. Almost no MVI online gets this right. Not the tutorials, not the "clean" repos, not even the open-source libraries.
Concurrent intents, ordered state reducing, process death, request synchronization, testable ViewModels, etc. Everyone solves one and breaks the rest.
So I'm packaging the whole thing:
MVI Blueprint - a scalable, testable solution for production apps
Ebook - the practical guideline on implementation and testing
Project - a sample project to see it in action
Migration AI Skill - migrates whatever you have now to MVI
Testing AI Skill - writes your tests on autopilot
5-in-1 production solution, ready to integrate into your team.