SharedFlow replay cache bug in Compose Multiplatform iOS
Meta description: SharedFlow replay caches silently re-fire navigation events on iOS in Compose Multiplatform. Decision matrix and fix for shared KMP ViewModels.
Tags: kotlin kmp multiplatform jetpackcompose architecture
TL;DR
Sharing a ViewModel across Android and iOS in Compose Multiplatform works until lifecycle semantics diverge. MutableStateFlow conflation protects you on Android but exposes a ghost emission trap on iOS recomposition — and SharedFlow replay caches make it worse. What follows is the exact decision matrix and mitigation pattern you need.
The core problem: platform lifecycle asymmetry
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 the foreground. iOS has no equivalent primitive in the KMP shared layer.
The mistake most teams make: they replicate Android’s collection pattern in the shared ViewModel and assume it transfers safely to iOS. It does not.
| 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 |
The ghost emission happens because iOS Compose recomposition can trigger a new collector on a SharedFlow with replay > 0 before the previous scope is cancelled. Android’s collectAsStateWithLifecycle is coordinated with the snapshot system tightly enough to prevent this. The iOS integration is not.
Affected version range
The behavior is reproducible across the following versions. No first-class lifecycle binding exists for shared ViewModels on iOS targets yet.
| Component | Confirmed Affected | Notes |
|---|---|---|
| Kotlin | 1.9.20 – 2.0.21 | Ghost emission present in all tested versions |
| Compose Multiplatform | 1.5.11 – 1.6.11 | No LifecycleOwner integration in shared layer |
| KMP Gradle Plugin | 0.9.x | No scoped lifecycle API for iOS targets |
The root cause is architectural: the shared ViewModel layer has no access to a platform LifecycleOwner on iOS, so scope lifetime is entirely manual. Until the Compose Multiplatform runtime ships a first-class lifecycle binding for iOS, the patterns below are your defensive baseline.
StateFlow conflation and the 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 and you always get the settled state.
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, producing an iOS-only intermediate UI state that is nearly impossible to catch in unit tests without platform-specific infrastructure.
// Shared ViewModel — fast updates expose snapshot mismatch on iOS
class SearchViewModel : ViewModel() {
private val _query = MutableStateFlow("")
val results: StateFlow<List<Item>> = _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.
The SharedFlow replay cache trap
// Replay cache fires on every iOS recomposition — ghost emission risk
val navigationEvent = MutableSharedFlow<Route>(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:
val navigationEvent = MutableSharedFlow<Route>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
Auditing existing flows with Turbine
Every SharedFlow with replay > 0 in your shared ViewModel is a candidate for this bug. The following Turbine test catches ghost emissions at the JVM layer — no iOS device required:
@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 and surfaces the misconfiguration before it reaches a device.
Backpressure strategy comparison
| Scenario | Recommended flow type | Buffer / overflow 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 |
What to do
Audit every SharedFlow with replay > 0 in your shared ViewModel. Drop the Turbine test above into your existing suite — it runs on the JVM, catches ghost emissions without a device, and takes under five minutes per flow. Any test that fails on expectNoEvents() is a live iOS navigation bug.
Explicitly cancel shared ViewModel scopes on iOS onDisappear. Do not assume the scope lifecycle maps to Android’s. Wire scope.cancel() into the iOS view lifecycle manually until Compose Multiplatform ships a first-class LifecycleOwner binding for shared ViewModels.
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.