Skip to content
All posts
June 12, 20265 min read

StateFlow vs LiveData: What Changed Across 22 Android Apps After Switching

The switch from LiveData to StateFlow was not a trend decision. It was driven by a specific crash pattern. Here is what LiveData was getting wrong, what StateFlow changed, and what it costs.

AndroidKotlinArchitecture
Share:

Around app number eight, I had a crash that took three days to trace. The stack trace pointed at a LiveData observer. The observer was receiving stale data in a newly created screen. The screen had been resumed, but the observer was firing before the ViewModel had finished emitting the correct state.

The root cause was observer lifecycle management. LiveData's observer callbacks fire based on lifecycle state changes. Under the right sequence of

code
onStart
,
code
onStop
, and
code
onResume
events — which happen frequently during configuration changes, task switching, and back-stack navigation — a LiveData observer can receive data in an order that the developer didn't anticipate.

I switched the entire fleet to StateFlow. Here is what changed.


The specific failure mode

LiveData wraps its observer callbacks in lifecycle awareness automatically. When you call

code
observe(viewLifecycleOwner, observer)
, LiveData manages when the observer receives updates based on the lifecycle owner's state.

The problem is that this management is implicit. You don't see it. The observer just fires, and you trust that it fires at the right time.

Under normal conditions, this works. Under specific conditions — particularly fragment back-stack management and configuration changes in close succession — the implicit lifecycle management produces observer callbacks in an order that violates the assumptions the UI code was written with.

The crash manifested as: screen A → navigate to screen B → navigate back to screen A → UI shows state from screen B briefly before correcting.

The cause: screen A's LiveData observer received a delivery from a previous emission that was queued during the lifecycle transition. LiveData's

code
onActive
callback triggered delivery of the last set value when the observer became active again, but the ViewModel hadn't yet reset to the correct state for screen A.

This is a known LiveData behavior. It is not a bug. It is how LiveData works. But it produced a real crash in production.


What StateFlow changes

StateFlow is not lifecycle-aware by itself. You collect it explicitly using

code
repeatOnLifecycle
in the UI layer:

kotlin
// LiveData — implicit lifecycle management
viewModel.uiState.observe(viewLifecycleOwner) { state ->
    render(state)
}

// StateFlow — explicit lifecycle management
viewLifecycleOwner.lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            render(state)
        }
    }
}

The explicit management is more code. It is also auditable. When something is wrong with the observation lifecycle, the failure is visible at the collection point — in code you wrote, in a scope you control.

code
repeatOnLifecycle(Lifecycle.State.STARTED)
cancels the collection when the lifecycle drops below STARTED and restarts it when it returns to STARTED. This is the same behavior LiveData provides, but made explicit.


What StateFlow requires in the ViewModel

LiveData has a

code
MutableLiveData
that you post values to. StateFlow has
code
MutableStateFlow
with an explicit initial value:

kotlin
// LiveData
private val _uiState = MutableLiveData<UiState>()
val uiState: LiveData<UiState> = _uiState

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

The initial value requirement is the most important difference. StateFlow requires you to define what the state is before any data arrives. This forces an explicit loading/empty/error state model. You can't leave the state undefined.

This is a constraint that improves the code. LiveData allows you to emit nothing until you have something. StateFlow requires you to decide what "nothing" looks like. That decision produces better UI states.


Flow operators that LiveData doesn't have

StateFlow is a Kotlin Flow, which means the full Flow operator set is available:

kotlin
// Combine two StateFlows
val combinedState = combine(
    viewModel.userState,
    viewModel.locationState
) { user, location ->
    CombinedState(user, location)
}

// Transform with debounce for search
val searchResults = searchQuery
    .debounce(300)
    .flatMapLatest { query ->
        repository.search(query)
    }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

LiveData has

code
Transformations.map
and
code
Transformations.switchMap
. They work. But they're not composable in the same way, and they don't have the rich operator set that Flow provides.

code
stateIn
deserves specific mention. It converts a cold Flow from the repository layer into a StateFlow that the UI can observe:

kotlin
val locations: StateFlow<List<Location>> = repository.getLocations()
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = emptyList()
    )

code
SharingStarted.WhileSubscribed(5000)
keeps the upstream flow active for 5 seconds after the last subscriber disappears — long enough to survive a configuration change without restarting the upstream collection. This pattern replaced a class of LiveData boilerplate that was managing upstream subscription lifecycle manually.


What it costs

StateFlow has real costs that LiveData doesn't:

No built-in Activity/Fragment support.

code
observe()
just works.
code
repeatOnLifecycle
requires understanding lifecycle scopes and cancellation. For developers new to Kotlin Coroutines, this is a meaningful learning curve.

Equality check skips duplicate emissions. StateFlow uses

code
equals()
to check if a new value differs from the current value. If you emit the same object reference with mutated fields, StateFlow won't propagate the update. LiveData always propagates. This requires working with immutable data classes — which is the correct approach anyway, but it's a constraint you have to be aware of.

SharedFlow for one-shot events. StateFlow holds and replays its current value to new collectors. For one-shot UI events (navigation, toast messages), replaying the last value is wrong. SharedFlow with

code
replay = 0
is the correct primitive:

kotlin
private val _events = MutableSharedFlow<UiEvent>()
val events: SharedFlow<UiEvent> = _events.asSharedFlow()

// Emit from ViewModel
viewModelScope.launch {
    _events.emit(UiEvent.NavigateTo(Screen.Detail))
}

This is more infrastructure than LiveData's

code
SingleLiveEvent
pattern, but it's explicit and testable.


The result across 22 apps

Since switching the fleet to StateFlow: zero crashes attributable to observer lifecycle management. The explicit collection pattern makes lifecycle-related bugs visible in code review rather than visible in production. The Flow operator set reduced a significant amount of LiveData transformation boilerplate.

The switch was not a rewrite. Each screen's ViewModel was updated incrementally — change the

code
MutableLiveData
to
code
MutableStateFlow
, add the initial value, update the collection in the fragment. The migration pattern is mechanical once you've done the first few.

LiveData still works. It is a stable, well-understood API with good lifecycle integration. But StateFlow's explicit lifecycle management, Flow operator composability, and requirement for initial state produce code that is easier to reason about and harder to break in unexpected ways. For a fleet of 22+ apps where any one of them needs to be debuggable without context reload, that matters.

Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this