---
title: "Kotlin Flow Internals: SharedFlow Replay Buffers, StateFlow Conflation, and the Hot/Cold Boundary"
published: true
description: "Deep dive into SharedFlow replay buffers, StateFlow conflation, and the hot/cold boundary — with ViewModel examples to prevent emission loss and memory leaks."
tags: [kotlin, android, architecture, mobile]
canonical_url: https://blog.mvpfactory.co/kotlin-flow-sharedflow-stateflow-deep-dive
---
What You Will Learn
By the end of this workshop you will understand why SharedFlow silently drops events during configuration changes, how StateFlow conflation depends entirely on your model's equals() implementation, and the one stateIn pattern that handles both cleanly in a ViewModel. Let me show you a pattern I use in every project.
Prerequisites
- Kotlin coroutines basics (launch, collect, scope)
- Android ViewModel and lifecycle awareness
- Familiarity with Jetpack Compose is helpful but not required
Step 1: Understand the Hot/Cold Boundary
Cold flows are lazy — they produce values only when collected, and each collector gets its own independent stream. Hot flows (SharedFlow, StateFlow) do not wait. They exist independently of their collectors. That distinction sounds academic until it causes a production regression.
Here is the minimal setup to get this working — a SharedFlow configured the way most teams write it by default:
val events = MutableSharedFlow<UiEvent>()
This is replay = 0, extraBufferCapacity = 0, onBufferOverflow = SUSPEND. If you call emit() before the UI has subscribed — which happens during a configuration change — the event is gone. Permanently. No crash. No log. Users land on the wrong screen.
Step 2: Configure SharedFlow for One-Shot Events
For navigation events, snackbars, or any one-shot UI signal, set extraBufferCapacity = 1:
val events = MutableSharedFlow<UiEvent>(extraBufferCapacity = 1)
| Parameter | Default | Implication |
|---|---|---|
replay |
0 | Late subscribers miss all prior emissions |
extraBufferCapacity |
0 | Emitter suspends if no active collectors |
onBufferOverflow |
SUSPEND |
Can deadlock on emit() from main thread |
This is not "consider setting" — just do it for one-shot events.
Step 3: Audit Your StateFlow Model Types
StateFlow always replays the last value and conflates identical emissions via equals(). That conflation only fires when equals() returns false. The docs do not make this obvious, but your model's equality semantics control whether a state update reaches the UI at all.
// Regular class — referential equality, NOT structural
class Item(val id: String, val label: String)
// Bypasses conflation — new instances, equals() returns false every time
_uiState.update { state ->
state.copy(items = state.items.map { Item(it.id, it.label) })
}
// Switch to data class to restore correct conflation behaviour
data class Item(val id: String, val label: String)
When profiling list-heavy screens with Android Studio's recomposition highlighter, spurious emissions from broken equality show up immediately — every frame turns red even when the underlying data has not changed. On mid-range devices this compounds into visible jank at scroll velocity.
Step 4: Scope Your Collectors Correctly
// Leaks the coroutine if not cancelled properly
lifecycleScope.launch {
viewModel.uiState.collect { render(it) }
}
// Correct: respect the UI lifecycle
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
On Compose, prefer collectAsStateWithLifecycle() from androidx.lifecycle:lifecycle-runtime-compose — it is the idiomatic equivalent and integrates directly with Compose's snapshot system:
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Step 5: Use stateIn for Repository-Backed State
Here is the gotcha that will save you hours. Converting a cold repository flow to a hot StateFlow with WhileSubscribed(5_000) keeps the flow active only when observed, with a 5-second grace window that survives configuration changes without restarting upstream work:
val uiState: StateFlow<UiState> = repository.dataFlow
.map { UiState(data = it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UiState()
)
Gotchas
-
Default SharedFlow drops events.
replay = 0+extraBufferCapacity = 0is a production bug waiting to ship. Always addextraBufferCapacity = 1for one-shot events. -
Conflation is not free. If your model types do not implement structural equality,
StateFlowwill not suppress redundant recompositions. Convert todata classor write a properequals(). -
Subscriber leaks are silent. An active collector holds a reference in the shared flow's subscriber set. Without
repeatOnLifecycle, your ViewModel retains a live subscriber count that prevents buffer garbage collection. -
stateIntimeout of 5_000ms is intentional. Drop it to zero and a quick screen rotation restarts your upstream repository call.
Conclusion
Default SharedFlow parameters will silently drop UI events during lifecycle transitions — set extraBufferCapacity = 1 for one-shot events. For ViewModel state backed by a repository, stateIn(WhileSubscribed(5_000)) paired with collectAsStateWithLifecycle() is the right call. And audit your model types: do not assume conflation is protecting you until you have checked equals() actually works the way you expect.
Further reading:
Top comments (0)