DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on Originally published at mvpfactory.io

Kotlin Coroutines Flow Operators Under the Hood

---
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
---
Enter fullscreen mode Exit fullscreen mode

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>()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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) }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
    )
Enter fullscreen mode Exit fullscreen mode

Gotchas

  • Default SharedFlow drops events. replay = 0 + extraBufferCapacity = 0 is a production bug waiting to ship. Always add extraBufferCapacity = 1 for one-shot events.
  • Conflation is not free. If your model types do not implement structural equality, StateFlow will not suppress redundant recompositions. Convert to data class or write a proper equals().
  • 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.
  • stateIn timeout 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)