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.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — StateFlow 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:
- Always has a value — hence the required initial value, and
.valuefor synchronous reads. - 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.
- 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.