MVP Factory
ai startup development

Kotlin Flow internals: SharedFlow vs StateFlow deep dive

KW
Krystian Wiewiór · · 6 min read

Meta description: Deep dive into SharedFlow replay buffers, StateFlow conflation, and the hot/cold boundary — with ViewModel examples to prevent emission loss and memory leaks.


TL;DR

SharedFlow and StateFlow are hot flows with fundamentally different emission semantics. Get them wrong and you’ll face silent emission loss, redundant recompositions, or subscriber leaks that only surface under real-world lifecycle pressure. This post walks through the internals so you know what you’re actually choosing.


The bug you won’t find in unit tests

You shipped a navigation event that was silently dropped on a measurable slice of Android 13 devices during configuration changes. The crash logs were clean. Users simply landed on the wrong screen. The root cause: a SharedFlow with default parameters and a UI subscriber that wasn’t ready at emission time.

Hot flows — SharedFlow and StateFlow — exist independently of their collectors. That distinction sounds academic until it causes a production regression. Cold flows are lazy: they produce values only when collected, and each collector gets its own independent stream. Hot flows don’t wait. Knowing exactly what each hot flow does when no one is listening is the difference between a reliable app and a one-star review.

Most teams get this wrong: they reach for SharedFlow or StateFlow because they’ve heard “use hot flows in ViewModels,” without understanding the backpressure and replay mechanics underneath each one.


SharedFlow: replay buffers and the emission loss trap

SharedFlow is a broadcast channel with configurable replay and buffer capacity:

val events = MutableSharedFlow<UiEvent>(
    replay = 0,
    extraBufferCapacity = 1,
    onBufferOverflow = BufferOverflow.DROP_OLDEST
)
ParameterDefaultImplication
replay0Late subscribers miss all prior emissions
extraBufferCapacity0Emitter suspends if no active collectors
onBufferOverflowSUSPENDCan deadlock on emit() from main thread

The dangerous combination is replay = 0 with extraBufferCapacity = 0. If you call emit() before the UI has subscribed — which is entirely possible during a configuration change — the event is gone. Permanently.

In my experience building production systems, one-shot UI events (navigation, snackbars) are the most common source of this bug. The fix is intentional:

// For one-shot events, buffer at least one
val events = MutableSharedFlow<UiEvent>(extraBufferCapacity = 1)

StateFlow: conflation and the recomposition tax

StateFlow is opinionated: it always replays the last value (implicit replay = 1) and conflates identical emissions via equals() comparison:

val uiState = MutableStateFlow(UiState())

Conflation is a feature, not a bug — but it only fires when equals() returns false. Your model’s equality semantics drive whether a state update reaches the UI at all.

The subtle trap is mixing data class containers with regular (non-data) model types. Kotlin’s List.equals() delegates to element equality, so if your items are data classes, identical content is correctly conflated. But if Item is a regular class without overriding equals(), every new instance fails the equality check:

// 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) })
}

// Switching to data class restores correct conflation behaviour
data class Item(val id: String, val label: String)

// Now equals() compares fields — identical content is correctly suppressed
_uiState.update { state ->
    state.copy(items = state.items.map { it.copy() })
}

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 hasn’t changed. On mid-range devices this compounds into visible jank at scroll velocity. Audit your model types before assuming conflation is protecting you.


ViewModel lifecycle: where subscriptions go wrong

The canonical mistake is collecting a flow without scoping it 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) }
    }
}

repeatOnLifecycle(STARTED) cancels collection when the app goes to the background, which matters for SharedFlow specifically: an active collector holds a reference in the shared flow’s subscriber set. Without cancellation, your ViewModel retains a live subscriber count that prevents the buffer from being garbage collected.

If you’re on Compose, prefer collectAsStateWithLifecycle() from androidx.lifecycle:lifecycle-runtime-compose — it’s the idiomatic equivalent of repeatOnLifecycle(STARTED) and integrates directly with Compose’s snapshot system, so you don’t need to wire a LaunchedEffect manually:

// Compose equivalent — lifecycle-aware, no LaunchedEffect required
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

Choosing between SharedFlow and StateFlow

Use CaseRecommendedReason
UI state (loading, data, error)StateFlowAlways has a current value; late subscribers safe
One-shot events (navigation, toast)SharedFlow(extraBufferCapacity=1)Buffered delivery; no conflation
Multi-subscriber broadcastSharedFlow(replay=N)Control exactly how much history late subscribers see
Derived/computed stateflow { }.stateIn(...)Lazy cold → hot conversion with sharing

stateIn deserves its own mention. Converting a cold repository flow to a hot StateFlow in the ViewModel with SharingStarted.WhileSubscribed(5_000) gives you the best of both worlds: the flow is active only when observed, with a 5-second grace window to survive 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()
    )

The short version

Default SharedFlow parameters will silently drop UI events during lifecycle transitions. Set extraBufferCapacity = 1 for one-shot events — not “consider setting,” just do it.

For ViewModel state backed by a repository, stateIn(WhileSubscribed(5_000)) is the right call. It survives configuration changes without restarting upstream work and pairs naturally with collectAsStateWithLifecycle() in Compose.

The conflation trap is the sneaky one. If your model types don’t implement structural equality, StateFlow won’t protect you from redundant recompositions. Convert to data class or write a proper equals(). Don’t assume conflation is working until you’ve actually checked.


#kotlin #android #jetpackcompose #architecture #mobile


Share: Twitter LinkedIn