Every few hundred checkouts, a support ticket arrived with the same story: “I paid, the money left my account, and the receipt screen never showed up.” The backend logs were spotless. The ViewModel logs were spotless. The ShowReceipt effect had been sent, and nobody had received it.
That bug lives in a gap between two ideas Kotlin Flow lets you mix freely: state, where only the latest value matters, and events, where every occurrence matters. The mechanism that blurs the line is conflation, and it is not the only way values vanish. This post walks through what conflation is, when it quietly becomes a data-loss bug, and how to pick a primitive that matches the meaning of what you’re sending.
What conflation is
Conflation means that when a producer outpaces its collector, intermediate values are discarded and the collector receives the most recent one. The collector never asks for this; it simply never gets a chance to look at the values it skipped.
StateFlow conflates by design. Here is a download screen that updates its progress in a tight loop:
class DownloadViewModel : ViewModel() { private val _progress = MutableStateFlow(0) val progress: StateFlow<Int> = _progress.asStateFlow() fun start() { viewModelScope.launch(Dispatchers.Default) { for (percent in 1..100) { _progress.value = percent // no suspension point between writes } } } } A UI that takes a few milliseconds per frame to render will see only a handful of values (the exact ones depend on dispatcher scheduling and frame timing, so the sequence is nondeterministic), not a hundred distinct values. The only guarantee is that the last value written is eventually delivered. For a progress bar this is the correct behavior: rendering stale numbers faster than the user can read them would only waste CPU. Conflation is the reason StateFlow is cheap, and it is why you can write to it from anywhere without worrying about a slow UI.
Two properties of StateFlow are worth keeping in mind, because they are conflation's quieter cousins:
data class Cart(val items: MutableList<String> = mutableListOf()) val cart = MutableStateFlow(Cart()) cart.value.items.add("green tea") // same instance, nothing assigned: no emission cart.value = cart.value // equal to the current value: ignored StateFlow compares each new value to the current one with equals and drops it when they match. Mutating an object in place never emits at all. Prefer immutable state and update { it.copy(...) }, which also gives you atomic read-modify-write.
When conflation is a feature
Conflation is exactly right whenever the latest value is the whole truth: a screen’s UI state, a search query, a connectivity flag, a playback position. Skipping the middle changes nothing the user can perceive, and you get natural backpressure handling for free.
The failure begins when something that is semantically an event (navigate, show a dialog, play a sound, confirm a purchase) gets modeled as state.
When conflation is a bug
Failure mode 1: the snackbar that ate itself
A very common shortcut is a nullable field in UI state:
data class CheckoutUiState(val snackbar: String? = null) // Two things go wrong in the same frame: _state.update { it.copy(snackbar = "Card declined") } _state.update { it.copy(snackbar = "Retry limit reached") } The collector may only ever see the second message, so the user never learns the card was declined. Worse, if the user taps Retry and the same error occurs, snackbar = "Card declined" equals the previous value (assuming it was never cleared), so StateFlow swallows it. The screen looks frozen.
Failure mode 2: a wrong belief about SharedFlow
The usual advice is “use SharedFlow for events", and the usual explanation is that it doesn't conflate. Both halves are only partly true. A default MutableSharedFlow() has replay = 0, extraBufferCapacity = 0 and BufferOverflow.SUSPEND:
private val _effects = MutableSharedFlow<CheckoutEffect>() // all defaults suspend fun confirm(orderId: String) { _effects.emit(CheckoutEffect.ShowReceipt(orderId)) _effects.emit(CheckoutEffect.NavigateToOrders) } With that configuration, emit suspends until every current subscriber has received the value, so nothing is conflated. Two other things happen instead:
- A slow subscriber suspends the emitter. A collector that does heavy work in its handler stalls whatever coroutine called
emit. - With no subscribers, the value is gone.
extraBufferCapacityonly buffers values for existing subscribers that are lagging. When nobody is collecting andreplay = 0,emitreturns immediately and the event evaporates, no matter how large the extra buffer is. Only areplay > 0cache retains values for future collectors, and it does so by replaying the last n values to every new collector, which is the wrong semantics for one-shot effects (a rotated screen would show the receipt again). That is the checkout bug: the user had backgrounded the app during the payment call, the lifecycle-aware collector was stopped, andShowReceiptwent nowhere.
tryEmit makes it worse: on a flow with no buffer and active subscribers it returns false when it cannot hand the value over, and most call sites ignore that result.
Failure mode 3: collectLatest is conflation with a cancel button
collectLatest cancels the handler for the previous value when a new one arrives. That is perfect for "search as you type" and dangerous for effects:
effects.collectLatest { effect -> when (effect) { is CheckoutEffect.ShowReceipt -> { delay(300) // let the transition finish receiptSheet.show(effect.orderId) // never reached if another effect lands first } CheckoutEffect.NavigateToOrders -> navigator.toOrders() } } If NavigateToOrders arrives within 300 ms, the receipt handler is cancelled mid-flight. The same happens with .conflate() and .buffer(Channel.CONFLATED), as well as with SharedFlow configured with DROP_OLDEST or DROP_LATEST. Each of these is a deliberate "drop it" policy. None of them belongs on an effect stream.
Comparing your options
Notice what the matrix says about the two SharedFlow rows in the middle. Neither conflates, and both still lose an event emitted into an empty room. If you need delivery guarantees across a lifecycle gap, a buffer on the SharedFlow isn't enough: you need something that holds events for a collector that isn't there yet.
Fixes that actually hold
Option A: a buffered SharedFlow when a collector is always present
If your collector is long-lived (a repository, a coordinator, a service-scoped bus), you can make a SharedFlow lossless under bursts:
private val _effects = MutableSharedFlow<CheckoutEffect>( extraBufferCapacity = 64, // absorb bursts onBufferOverflow = BufferOverflow.SUSPEND, // back-pressure, never drop ) val effects: SharedFlow<CheckoutEffect> = _effects.asSharedFlow() This removes conflation and protects against slow collectors, but it does not help when nobody is subscribed. For UI work, that disqualifies it.
Option B: a Channel for single-consumer UI effects
A Channel keeps elements until someone receives them, so effects emitted while the screen is stopped are waiting when it comes back:
class CheckoutViewModel( private val payments: PaymentsRepository, ) : ViewModel() { private val _effects = Channel<CheckoutEffect>(capacity = Channel.BUFFERED) val effects: Flow<CheckoutEffect> = _effects.receiveAsFlow() fun confirm(orderId: String) { viewModelScope.launch { payments.confirm(orderId) _effects.send(CheckoutEffect.ShowReceipt(orderId)) _effects.send(CheckoutEffect.NavigateToOrders) } } } And on the UI side, collect only while the screen is visible:
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.effects.collect { effect -> render(effect) } } } A few details separate a working version from a subtly broken one:
- Use
receiveAsFlow(), notconsumeAsFlow().consumeAsFlow()cancels the channel when its collector is cancelled, andrepeatOnLifecyclecancels its collector every time the screen stops. After the first rotation or backgrounding, your channel is closed and later sends throw or are lost. - Prefer
BUFFEREDoverUNLIMITED. An unlimited channel never suspends, sotrySendalways succeeds. If the UI is gone for a long time or an effect source is chatty, memory grows without bound. A bounded buffer makes the producer's behavior explicit. - A bounded buffer shifts the failure mode; it doesn’t remove it.
Channel.BUFFEREDholds 64 elements by default. If the screen stays gone while more than that are queued, furthersendcalls suspend, and each suspended coroutine stays alive inviewModelScopeuntil the UI returns. For effects triggered by user taps this is rarely reachable, but if a producer can outrun the UI for long periods, decide on purpose: usetrySendand handle the failure, coalesce at the source, or move that data to state. - A
Channelhas fan-out semantics. Two collectors will split the elements between them instead of each seeing all of them. If more than one consumer needs every effect, aChannelis the wrong tool. - Handlers should be short and non-suspending. An element is considered delivered once it’s received. If the screen stops while a suspending handler is half-way done, that effect is not redelivered.
Option C: make the effect part of the state
The Android architecture guidance leans toward representing one-off UI events as state that the UI acknowledges. This sidesteps the question of “who is collecting” entirely, but you must design it so conflation can’t hurt you. A single nullable field loses messages. A queue with identities does not:
import java.util.concurrent.atomic.AtomicLong data class PendingEffect(val id: Long, val effect: CheckoutEffect) data class CheckoutUiState( val total: Money = Money.ZERO, val pending: List<PendingEffect> = emptyList(), ) class CheckoutViewModel : ViewModel() { private val _state = MutableStateFlow(CheckoutUiState()) val state: StateFlow<CheckoutUiState> = _state.asStateFlow() private val nextId = AtomicLong(0) // safe even if enqueue() is called off the main thread private fun enqueue(effect: CheckoutEffect) { val id = nextId.getAndIncrement() // outside update{}: its lambda can be re-run on CAS retry _state.update { it.copy(pending = it.pending + PendingEffect(id, effect)) } } fun onEffectHandled(id: Long) { _state.update { s -> s.copy(pending = s.pending.filterNot { it.id == id }) } } } Conflation is harmless here because each emission carries the whole queue. The UI renders pending.firstOrNull() and calls onEffectHandled(id) when done. The cost is more boilerplate and the need to make handlers idempotent: after a configuration change, the same pending effect may be rendered again until it is acknowledged.
A decision path you can apply in code review
Testing for the loss, not around it
The reason this bug ships is that the natural unit test collects first and emits second, which is the one ordering where a plain SharedFlow works. Flip it: send while nobody is listening.
import app.cash.turbine.test import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.Rule import org.junit.Test import org.junit.rules.TestWatcher import org.junit.runner.Description import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) class MainDispatcherRule( val dispatcher: TestDispatcher = StandardTestDispatcher(), ) : TestWatcher() { override fun starting(description: Description) = Dispatchers.setMain(dispatcher) override fun finished(description: Description) = Dispatchers.resetMain() } class CheckoutViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() @Test fun `receipt effect survives a stopped UI`() = runTest(mainDispatcherRule.dispatcher) { val vm = CheckoutViewModel(FakePayments()) vm.confirm("A-17") // launches on viewModelScope (Dispatchers.Main) advanceUntilIdle() // let both sends complete before anyone collects vm.effects.test { // Turbine: collector arrives late, on purpose assertEquals(CheckoutEffect.ShowReceipt("A-17"), awaitItem()) assertEquals(CheckoutEffect.NavigateToOrders, awaitItem()) cancelAndIgnoreRemainingEvents() } } } Passing the rule’s dispatcher into runTest makes the test body and viewModelScope share one scheduler, so advanceUntilIdle() really does run the ViewModel's coroutine. Against a MutableSharedFlow<CheckoutEffect>() the same test fails with a Turbine timeout on the first awaitItem(), which reproduces the production symptom.
For StateFlow assertions, remember the opposite: assert on the final value or on the properties you really need, because intermediate emissions can legitimately be skipped.
Checklist
- Ask of every flow: is this a value or an occurrence? Values go in
StateFlow; occurrences need delivery semantics. - Never ship an effect through a nullable state field. Use a queue with ids if you want effects in state.
- A
SharedFlowis a broadcast to whoever is listening now. It is not a mailbox. - Treat
conflate(),collectLatest,DROP_OLDESTandDROP_LATESTas explicit data-loss opt-ins. - For single-consumer UI effects, use
Channel(BUFFERED)withreceiveAsFlow()andrepeatOnLifecycle. - Write the test that emits before anyone collects.
Conflation is not the villain. It makes UI state cheap and robust. The problem is applying it to things that were never meant to be summarized by their latest value.
🧵 Stop Treating Coroutines Like Magic Keywords
If “just add launch and hope” describes how your team handles concurrency, I’d recommend “Kotlin Coroutines Under the Hood” — a 25-chapter deep dive that goes past the syntax and down to the bytecode: the Continuation-Passing Style transform, the compiler-generated state machine, and exactly why a suspended coroutine costs a few dozen bytes while a blocked thread costs a megabyte.
It walks through structured concurrency and Job hierarchies, dispatcher internals (Default vs IO vs Unconfined), cooperative cancellation and exception aggregation, Flow’s cold-stream semantics and the full operator vocabulary, StateFlow/SharedFlow’s hot-broadcast model, Channels and fan-in/fan-out patterns, Mutex/Semaphore/actors for shared state, and a complete Android case study retrofitting a legacy callback-driven app onto coroutines and Flow — including a 100,000-coroutines-vs-100,000-threads benchmark lab you can run yourself.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover.
Originally published on Medium.







