5 min read

StateFlow vs SharedFlow: Choose by Replay Semantics, Not Habit

The StateFlow/SharedFlow decision is really one question: should a late subscriber learn what it missed? State says yes, events say no. Plus the three behaviours — conflation, deduplication and replay — that cause the bugs.

AndroidKotlinCoroutinesFlowState Management
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DRStateFlow models a value that always exists: new collectors immediately receive the current one, emissions are conflated, and equal values are deduplicated. SharedFlow models events that happened: late collectors receive only what replay allows, which defaults to nothing. The decision reduces to one question — if someone subscribes a second late, should they know? Using StateFlow for navigation or one-shot events is the source of the classic "it navigates again after rotation" bug.

The real question

Almost every debate I've been in about these two types was actually a debate about late subscribers, with nobody naming it. On Android, late subscribers aren't hypothetical — they're what happens on every configuration change, every time a screen returns from the background, and every time repeatOnLifecycle restarts collection.

So the framing that resolves it:

When a new collector arrives after the fact, should it learn what it missed?

  • Yes → it's state. The current value is meaningful on its own. StateFlow.
  • No → it's an event. It was meaningful at a moment in time. SharedFlow.

StateFlow: a value that always exists

private val _uiState = MutableStateFlow(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()

Three properties follow, and all three are usually what you want for state:

  1. Always has a value — hence the required initial value, and .value for synchronous reads.
  2. Conflated — a slow collector may skip intermediate values and see only the latest. Correct for state: nobody needs the loading spinner you showed for 4ms.
  3. Deduplicated by equals — emitting an equal value notifies nobody. Correct for state: re-rendering an identical screen is waste.

SharedFlow: events that happened

private val _effects = MutableSharedFlow<Effect>()   // replay = 0
val effects: SharedFlow<Effect> = _effects.asSharedFlow()

No initial value, no .value, no deduplication, and a configurable replay that defaults to 0. A collector that arrives late has genuinely missed the event — which is the correct semantics for "navigate back" or "show a snackbar".

The bug this prevents

// Wrong: a one-shot event modelled as state
private val _destination = MutableStateFlow<Screen?>(null)

fun onSaved() { _destination.value = Screen.Detail }

Rotate the device. The screen is recreated, collection restarts, the new collector immediately receives the current value — still Screen.Detail — and you navigate a second time. The ticket arrives as "back button is broken", and it reproduces only on rotation, which is why it survives QA.

The usual patch is to null the value after handling it, which is a state machine you now maintain by hand and which races with the next emission. The type was simply wrong.

// Right
private val _effects = MutableSharedFlow<Effect>()
fun onSaved() { viewModelScope.launch { _effects.emit(Effect.Navigate(Screen.Detail)) } }

Three behaviours that catch people out

Deduplication is not optional. StateFlow compares with equals. If your "event" is RetryTapped and the user taps twice, the second one does not arrive. This alone disqualifies StateFlow for anything the user can repeat.

Conflation loses values. Emit 100 values rapidly into a StateFlow and a slow collector may observe three. For a progress percentage, ideal. For a queue of analytics events, data loss.

SharedFlow(replay = 1) is not a StateFlow. It's closer, but it doesn't deduplicate, has no .value, and doesn't require an initial value. If you find yourself building one to imitate StateFlow, you probably want StateFlow — and if you deliberately want repeats and replay, write down why, because the next reader will assume it's a mistake.

Converting a cold Flow into state

val uiState: StateFlow<UiState> = repository.observeUser()
    .map(UiState::Content)
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = UiState.Loading,
    )

WhileSubscribed(5_000) is the Android-shaped choice: keep the upstream alive for five seconds after the last collector leaves, so a rotation doesn't tear down and restart a network call, but a genuine background transition does stop the work.

The rule

Pick by replay semantics, not by which type you used last time:

StateFlow SharedFlow
Late subscriber gets current value only replay items (default none)
Duplicate values dropped delivered
Fast emissions conflated buffered per config
Models state events

If the answer to "should a late subscriber know?" is yes, it's state. If it's no, it's an event. Everything else is a consequence.

Keep reading