---
title: "Kotlin Coroutines Flow in Compose Multiplatform: The StateFlow vs SharedFlow Decision That Determines UI Consistency"
published: true
description: "SharedFlow replay caches silently re-fire navigation events on iOS in Compose Multiplatform. Here is the exact decision matrix and fix for shared KMP ViewModels."
tags: [kotlin, mobile, architecture, api]
canonical_url: https://blog.mvpfactory.co/kotlin-coroutines-flow-compose-multiplatform-shared-viewmodel
---
## What We Are Building
By the end of this tutorial you will understand exactly why sharing a ViewModel across Android and iOS in Compose Multiplatform works fine — until lifecycle semantics diverge. You will walk away with a decision matrix for `StateFlow` vs `SharedFlow`, a Turbine test that catches ghost emissions on the JVM without a device, and the explicit cancellation pattern that prevents iOS navigation bugs in production.
**Prerequisites:** Familiarity with Kotlin Coroutines, basic KMP project setup, and Compose Multiplatform 1.5+.
---
## The Core Problem: Platform Lifecycle Asymmetry
Let me show you a pattern I see break in every shared ViewModel project.
Android's `LifecycleOwner` and iOS's `UIViewController` lifecycle do not map cleanly. On Android, `repeatOnLifecycle(STARTED)` gates collection — cancelling and restarting the coroutine as the component moves in and out of foreground. iOS has no equivalent primitive in the KMP shared layer.
Here is the exact divergence table you need to internalize:
| Concern | Android | iOS (KMP shared) |
|---|---|---|
| Lifecycle scope | `viewModelScope` + `repeatOnLifecycle` | Manual `CoroutineScope`, custom cancel |
| Recomposition trigger | Snapshot system + `collectAsStateWithLifecycle` | Compose for iOS snapshot + manual collect |
| `StateFlow` conflation | Drops intermediate values safely | Same semantics, but recomposition timing differs |
| `SharedFlow(replay=1)` on re-subscribe | Replays last value once | Replays on every recomposition cycle — ghost emissions |
| Scope cancellation trigger | `ON_STOP` lifecycle event (automatic) | None — must call `scope.cancel()` in `onDisappear` |
This is confirmed across Kotlin 1.9.20–2.0.21 and Compose Multiplatform 1.5.11–1.6.11. The root cause is architectural: no first-class `LifecycleOwner` binding exists for shared ViewModels on iOS targets yet.
---
## Step 1: Understand the StateFlow Snapshot Mismatch
`MutableStateFlow` uses conflation — if a new value arrives before the collector resumes, intermediate values are dropped and only the latest is delivered. On Android, Compose's snapshot system reads `StateFlow` synchronously during the composition phase. Conflation behaves as intended.
On iOS, snapshot reads occur at a different point in the render loop. A fast-updating `StateFlow` can deliver a transitional value into a composition pass that Android would have skipped entirely.
kotlin
// Shared ViewModel — fast updates expose snapshot mismatch on iOS
class SearchViewModel : ViewModel() {
private val _query = MutableStateFlow("")
val results: StateFlow> = _query
.debounce(300)
.flatMapLatest { repo.search(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
`SharingStarted.WhileSubscribed(5000)` keeps the upstream alive for 5 seconds after the last subscriber drops on Android, surviving configuration changes cleanly. On iOS, there is no lifecycle event to trigger a subscriber drop unless you explicitly cancel the scope in `onDisappear`.
---
## Step 2: Fix the SharedFlow Replay Cache Trap
Here is the gotcha that will save you hours.
kotlin
// This fires on every iOS recomposition — ghost emission risk
val navigationEvent = MutableSharedFlow(replay = 1)
On Android this is a standard one-shot event pattern — the replay cache is consumed and the collector advances. On iOS, if Compose triggers a recomposition and re-subscribes to the flow, the cached `Route` replays, firing a navigation event that already executed. Users get pushed back to a screen they already dismissed.
The fix is three parameters:
kotlin
val navigationEvent = MutableSharedFlow(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
Zero replay. One slot of buffer capacity. Drop the oldest if that slot is full. This is the minimal setup to get this working correctly on both platforms.
---
## Step 3: Audit Existing Flows with Turbine
Every `SharedFlow` with `replay > 0` in your shared ViewModel is a candidate for this bug. The docs do not mention this, but you can catch ghost emissions entirely at the JVM layer — no iOS device required.
kotlin
@test
fun navigation event does not replay on re-subscription() = runTest {
val vm = NavigationViewModel()
vm.navigationEvent.test {
vm.navigateTo(Route.Detail)
assertEquals(Route.Detail, awaitItem())
cancelAndIgnoreRemainingEvents()
}
// Simulate iOS recomposition triggering a new collector
vm.navigationEvent.test {
expectNoEvents() // Fails if replay > 0
}
}
This runs in under a second in your existing test suite. Any test that fails on `expectNoEvents()` is a live iOS navigation bug.
---
## Step 4: Apply the Full Backpressure Decision Matrix
markdown
| Scenario | Flow type | Config |
|-----------------------------------|--------------------|-----------------------------------------------|
| UI state (loading, data, error) | StateFlow | Conflation built-in, no extra config |
| One-shot UI events (nav, snackbar)| SharedFlow | replay=0, extraBufferCapacity=1, DROP_OLDEST |
| Streaming data (search, feed) | StateFlow via stateIn | replay=1 implicit, WhileSubscribed + debounce |
| Cross-platform side effects | SharedFlow | replay=0, explicit scope.cancel() in onDisappear |
---
## Gotchas
**Scope cancellation is not automatic on iOS.** Wire `scope.cancel()` into `onDisappear` manually for every shared ViewModel. Do not assume the scope lifecycle maps to Android's — it does not, and the failure mode is silent.
**Intermediate iOS UI states are nearly impossible to catch without platform-specific infrastructure.** The Turbine test above is your first line of defence precisely because it runs on the JVM.
**`SharingStarted.WhileSubscribed` behaves differently cross-platform.** On Android the subscriber drop is triggered by a lifecycle event. On iOS you are responsible for it.
**The version range is wide.** Ghost emissions are reproducible across all Kotlin versions from 1.9.20 to 2.0.21. Do not assume a KMP upgrade has fixed this silently.
---
## Conclusion
The rule is simple: `StateFlow` for state, `SharedFlow(replay=0)` for events — everywhere, without exception. `StateFlow` conflation is the only Flow primitive with consistent cross-platform snapshot semantics across the affected version range. Everything else requires defensive configuration that most teams skip until something breaks in production.
Drop the Turbine test into your suite now. It takes under five minutes per flow and catches the misconfiguration before it reaches a device. Then audit every `SharedFlow` with `replay > 0` in your shared ViewModels and apply the three-parameter fix.
**Resources:**
- [Kotlin Coroutines SharedFlow docs](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-shared-flow/)
- [Turbine testing library](https://github.com/cashapp/turbine)
- [Compose Multiplatform lifecycle tracking issue](https://github.com/JetBrains/compose-multiplatform/issues)
Top comments (0)