Practical Kotlin Deep Dive · Comparison

Kotlin Flow vs LiveData: Which to Pick in 2026

A fair, code-first comparison of Kotlin Flow vs LiveData for Android: cold vs hot streams, lifecycle-aware collection, testing, KMP, and migration.

Jaewoong Eum
Jaewoong Eum (skydoves)

Google Developer Expert · April 22, 2026 · 13 min read

You have a ViewModel holding a MutableLiveData<UiState>, the screen observes it with observe(viewLifecycleOwner), and the Android team has been talking about migrating to StateFlow for a quarter now. You are not sure whether the ceremony of repeatOnLifecycle and viewModelScope.launch is worth it, or whether your existing LiveData pipeline is quietly fine. The question looks like a taste debate, but it is really a decision about threading, lifecycle ownership, operator surface, and where your code will live five years from now.

In this article, you'll explore how LiveData and Kotlin Flow differ in threading model, lifecycle handling, operator coverage, Kotlin Multiplatform reach, migration cost, and testing ergonomics, and you'll walk away with a concrete rule for when to pick each one on a real Android codebase.

TL;DR

  • Pick Flow (StateFlow / SharedFlow) for new code. It is the direction the ecosystem has moved, it has real operators, and it works on Kotlin Multiplatform.
  • Keep LiveData for stable Android only code that already wires it through Room and the Data Binding library. There is no deprecation pressure and rewrites rarely pay off.
  • One line summary: LiveData is a lifecycle aware value holder for Android views. Flow is a general purpose asynchronous stream primitive with a richer operator surface and zero Android coupling. The kotlin flow vs livedata decision is really a decision between a narrow UI helper and a language level streaming API.

At a glance comparison of Kotlin Flow vs LiveData

The feature matrix below is the first thing I look at when someone asks me whether to use Flow or LiveData. I have tried to keep it honest. Both tools have legitimate strengths.

FeatureLiveDataKotlin Flow (StateFlow / SharedFlow)
Threading modelEmits on main thread onlyAny dispatcher; flowOn switches upstream context
Lifecycle awarenessBuilt in via observe(owner, ...)Opt in via repeatOnLifecycle or collectAsStateWithLifecycle
Hot / coldAlways hot; holds latest valueCold by default (flow {}); hot via StateFlow / SharedFlow
BackpressureImplicit conflation on mainExplicit: buffer, conflate, collectLatest
Operatorsmap, switchMap, distinctUntilChanged40+ operators: debounce, combine, flatMapLatest, retry
MultiplatformAndroid onlyJVM, Android, iOS, JS, native
TestingInstantTaskExecutorRule + Observer boilerplaterunTest + Turbine, fully deterministic
Initial value requiredNo (nullable)Yes for StateFlow; optional for SharedFlow
One shot eventsPoor fit (redelivers on resubscribe)Natural via Channel.receiveAsFlow() or SharedFlow(replay=0)
Compose integrationobserveAsState() (experimental history)collectAsStateWithLifecycle() (recommended)
Learning curveLow, one conceptMedium, requires understanding coroutines

The rest of this article unpacks those rows. I will not pretend the decision is unanimous. It genuinely depends on the shape of your codebase.

How LiveData works

LiveData is a small, focused class from androidx.lifecycle. It holds a single value, lets observers subscribe, and only delivers updates to observers whose LifecycleOwner is in at least the STARTED state. When an observer moves to DESTROYED, LiveData removes it automatically. That one feature, automatic observer cleanup tied to Android lifecycle, is the reason LiveData exists.

A minimal LiveData backed ViewModel looks like this:

class ProfileViewModel(
    private val repo: ProfileRepository,
) : ViewModel() {
 
    private val _uiState = MutableLiveData<ProfileUiState>(ProfileUiState.Loading)
    val uiState: LiveData<ProfileUiState> = _uiState
 
    fun refresh() {
        viewModelScope.launch {
            _uiState.value = ProfileUiState.Loading
            _uiState.value = runCatching { repo.fetch() }
                .fold(
                    onSuccess = { ProfileUiState.Content(it) },
                    onFailure = { ProfileUiState.Error(it.message.orEmpty()) },
                )
        }
    }
}

And the Fragment side:

viewModel.uiState.observe(viewLifecycleOwner) { state ->
    render(state)
}

This is genuinely pleasant. No coroutine scope, no dispatcher, no repeatOnLifecycle. For a small Android only app it is the path of least resistance.

Where LiveData starts to strain:

  1. Main thread only. setValue must run on the main thread. postValue hops you there. If you emit a large batch via postValue, LiveData conflates intermediate values silently, you only see the last one. That is fine for UI state, surprising for anything else.
  2. No real operators. Transformations.map and switchMap exist, but you cannot debounce, combine two sources with a custom reducer, retry with exponential backoff, or buffer without writing your own helper.
  3. Redelivery on resubscribe. When a Fragment view is recreated (configuration change, backstack return), its new observer immediately receives the latest value. This is great for UI state and terrible for one shot events like "navigate to checkout" or "show a snackbar", hence the long history of SingleLiveEvent and Event<T> workarounds.
  4. Android only. LiveData imports android.os.Handler and android.arch.core.executor. It cannot be shared with an iOS or server module.

None of these are bugs. LiveData was designed in 2017 to solve one specific problem, "Activity aware observable state", and it solves that problem well. It just never grew into a general streaming primitive.

One more subtlety worth flagging: LiveData's lifecycle awareness is view oriented. Inside a ViewModel you often want to observe a repository's LiveData to drive a transformation, but ViewModel is not a LifecycleOwner. The workarounds (observeForever with manual cleanup, Transformations.switchMap chains, or converting to Flow internally) all point at the same seam: LiveData was designed for the UI layer, and it creaks once you try to use it as the ViewModel layer's primary streaming primitive.

How Flow works

Flow is a cold asynchronous stream that lives in kotlinx.coroutines. If you examine the public interface, it is almost comically small:

public interface Flow<out T> {
    public suspend fun collect(collector: FlowCollector<T>)
}

That is the entire contract. A Flow is a passive blueprint, nothing runs until a terminal operator (usually collect) is called. Every intermediate operator like map or filter returns a new Flow that wraps the previous one, building a cold pipeline that only activates on collection.

For UI state you usually do not use raw flow {}. You reach for one of the two hot specializations:

  • StateFlow<T>: holds a single current value, requires an initial value, conflates fast updates, and replays the current value to every new collector. This is the Flow analogue of LiveData.
  • SharedFlow<T>: broadcasts values to multiple collectors with configurable replay and buffer. Good for events.

The same ViewModel, rewritten with StateFlow:

class ProfileViewModel(
    private val repo: ProfileRepository,
) : ViewModel() {
 
    private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
    val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
 
    fun refresh() {
        viewModelScope.launch {
            _uiState.value = ProfileUiState.Loading
            _uiState.value = runCatching { repo.fetch() }
                .fold(
                    onSuccess = { ProfileUiState.Content(it) },
                    onFailure = { ProfileUiState.Error(it.message.orEmpty()) },
                )
        }
    }
}

On the collector side, Flow is not lifecycle aware on its own. You wire lifecycle explicitly:

viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            render(state)
        }
    }
}

repeatOnLifecycle(STARTED) cancels the inner coroutine when the view goes below STARTED and restarts it when it comes back. This is functionally equivalent to observe(viewLifecycleOwner), just with six lines of ceremony instead of one.

In Compose it collapses back to one line:

val state by viewModel.uiState.collectAsStateWithLifecycle()

What you gain for those extra lines of lifecycle plumbing:

  • A large operator library: debounce, throttleFirst, combine, flatMapLatest, retry, stateIn, shareIn, and so on.
  • Free dispatcher control with flowOn(Dispatchers.IO) so your database query runs off the main thread without wrapping every repository call in withContext.
  • Portability. The same StateFlow<ProfileUiState> works identically on iOS via Kotlin Multiplatform.
  • A testing story that does not require InstantTaskExecutorRule or androidx.arch.core.testing.

It is worth understanding why Flow needs explicit lifecycle wiring. Under the hood, a Flow does nothing until collect is called, and collect is a regular suspend function running inside a CoroutineScope. Lifecycle cancellation is just scope cancellation. repeatOnLifecycle launches a child coroutine when the owner reaches the target state and cancels it on the way down, structurally identical to how viewModelScope is cancelled in ViewModel.onCleared(). There is no magic. It is structured concurrency applied to the lifecycle graph. That uniformity is why the same StateFlow can be collected from a ViewModel (viewModelScope), a Fragment (viewLifecycleOwner.lifecycleScope), a worker (WorkManager's coroutineScope), or a test (TestScope) without changing its declaration.

The five decision points for Kotlin Flow vs LiveData

Here is how I actually decide. Every case below is one I have hit on a real production codebase.

1. Simple UI state holder, either works, StateFlow has a slight edge

If all you need is "hold the current screen state, update from ViewModel, render in UI", both tools are fine. I reach for StateFlow because:

  • It forces you to declare an initial value, which eliminates a category of "observed null on first emission" bugs.
  • It composes with Flow operators if the requirement grows (for example, "debounce search input into this state").
  • collectAsStateWithLifecycle() is the current recommended Compose integration.

If you are not using Compose and your team already has LiveData muscle memory, it is not wrong to stay with LiveData here. Consistency across a codebase usually beats micro optimizing one screen.

One thing to watch: if you expose StateFlow from a ViewModel and your screen is backed by XML views, make sure you collect with repeatOnLifecycle(STARTED) (not lifecycleScope.launch alone). A plain lifecycleScope.launch { flow.collect { ... } } keeps collecting while the view is in the backstack, which means you are holding UI references from a background coroutine. That is the single most common Flow on Android bug I review, and it does not exist with LiveData because observe(viewLifecycleOwner) handles it for you. The trade off is real: Flow gives you control but also gives you the rope.

2. One shot events, Flow wins, LiveData leaks or invites hacks

This is the clearest Flow win. LiveData redelivers its latest value to every new observer, which means a "navigate to checkout" event fires again after a configuration change. The ecosystem responded with SingleLiveEvent, Event<T> wrappers, and consumable state patterns. All of these are workarounds for a shape mismatch.

With Flow, a Channel exposed as a flow is the natural primitive. Each event is delivered exactly once to a single collector:

class CheckoutViewModel : ViewModel() {
    private val _events = Channel<CheckoutEvent>(Channel.BUFFERED)
    val events: Flow<CheckoutEvent> = _events.receiveAsFlow()
 
    fun submit() = viewModelScope.launch {
        _events.send(CheckoutEvent.NavigateToConfirmation)
    }
}

If you need multiple collectors or a small replay window, MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) fills the same role.

3. Cross-platform code, Flow wins, LiveData cannot cross the boundary

LiveData imports android.os classes. The moment you add a :shared Kotlin Multiplatform module that needs to publish state to both Android and iOS, LiveData is off the table. StateFlow works untouched on iOS, exposed to Swift through the Kotlin/Native bridge.

Even if you have no KMP plans today, I have been on two projects where "Android only" became "Android and iOS" in the span of a quarter. Defaulting to Flow in shared modules keeps that door open.

4. Heavy transformations, Flow wins decisively

Any time your reactive chain includes debouncing, combining multiple sources, retrying with backoff, or switching inner streams, Flow is where the operators live. A classic search as you type pipeline:

val results: StateFlow<List<Result>> = queryFlow
    .debounce(300)
    .distinctUntilChanged()
    .filter { it.length >= 2 }
    .mapLatest { query -> repo.search(query) }
    .catch { emit(emptyList()) }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = emptyList(),
    )

Trying to replicate this with Transformations.switchMap and custom helpers is possible, but you are reimplementing operators that ship in kotlinx.coroutines. SharingStarted.WhileSubscribed(5_000) alone, the "stop the upstream 5 seconds after the last observer leaves, restart on resubscribe" policy, has no clean LiveData equivalent.

5. Legacy codebase with Room and LiveData, stay put

If your repository layer returns LiveData<List<User>> from Room, your data binding layouts are wired with <variable type="LiveData..." />, and your team ships features confidently, a Flow migration is a cost with little upside. Room supports both return types. Both observe correctly. Both handle configuration change. Rewriting for purity is engineering theater.

The honest rule I use: migrate the file when you are editing it for another reason. Green fields get Flow. Brown fields get Flow when they are already touched by a real feature change.

Migration in practice: turning LiveData into Flow

When you do migrate, the conversion is mostly mechanical. Here are the three transformations I apply, with before and after pairs.

ViewModel state holder

Before:

class CartViewModel : ViewModel() {
    private val _items = MutableLiveData<List<CartItem>>(emptyList())
    val items: LiveData<List<CartItem>> = _items
 
    fun add(item: CartItem) {
        _items.value = _items.value.orEmpty() + item
    }
}

After:

class CartViewModel : ViewModel() {
    private val _items = MutableStateFlow<List<CartItem>>(emptyList())
    val items: StateFlow<List<CartItem>> = _items.asStateFlow()
 
    fun add(item: CartItem) {
        _items.update { it + item }
    }
}

Two wins already: _items.update { ... } is atomic under contention, and the items.value type is List<CartItem> instead of List<CartItem>?.

Fragment and Activity observation

Before:

viewModel.items.observe(viewLifecycleOwner) { items ->
    adapter.submitList(items)
}

After:

viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.items.collect { items ->
            adapter.submitList(items)
        }
    }
}

If this boilerplate bothers you (it should), extract an extension:

inline fun <T> Fragment.collectWhenStarted(
    flow: Flow<T>,
    crossinline block: suspend (T) -> Unit,
) {
    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            flow.collect { block(it) }
        }
    }
}

Room

If your DAO returned LiveData<List<User>>, change the return type:

@Query("SELECT * FROM user")
fun users(): Flow<List<User>>

Room will push a new list every time the underlying table changes. No other DAO changes required. Room has supported Flow return types for years.

Nullability

LiveData's value is always nullable on the Kotlin side because Java does not know about your non-null generic. StateFlow<T> is strictly typed, value is T, never T?. This means the migration often surfaces latent null handling that was being hidden by LiveData's permissive typing. Treat it as a free correctness audit.

For a deeper walk through of why suspend functions, dispatchers, and Flow behave the way they do under the hood, I wrote a separate guide at /en/guides/kotlin-coroutines-explained and a companion piece on the continuation passing style transform at /en/guides/suspend-function-internals.

Testing Kotlin Flow vs LiveData

Testing is where the two approaches diverge most sharply.

Testing LiveData

The canonical pattern uses InstantTaskExecutorRule to force LiveData's main thread executor to run synchronously, plus an observer to capture values:

class CartViewModelLiveDataTest {
    @get:Rule
    val rule = InstantTaskExecutorRule()
 
    @Test
    fun add_appendsItem() {
        val vm = CartViewModel()
        val observer = Observer<List<CartItem>> {}
        vm.items.observeForever(observer)
 
        vm.add(CartItem(id = "1"))
 
        assertEquals(1, vm.items.value?.size)
        vm.items.removeObserver(observer)
    }
}

The observeForever and removeObserver dance is easy to forget and leaks observers in the test JVM if you do. Helper libraries exist but are third party surface area.

Testing Flow

With runTest and Turbine the test is more direct:

class CartViewModelFlowTest {
 
    @Test
    fun add_appendsItem() = runTest {
        val vm = CartViewModel()
 
        vm.items.test {
            assertEquals(emptyList<CartItem>(), awaitItem())
 
            vm.add(CartItem(id = "1"))
            assertEquals(1, awaitItem().size)
 
            cancelAndIgnoreRemainingEvents()
        }
    }
}

runTest provides a TestDispatcher that controls virtual time. delay(1000) returns immediately unless you explicitly advance. Turbine's test { } collects the flow in a dedicated job and gives you awaitItem(), expectNoEvents(), and cancellation helpers. You can assert on ordered emissions, test debounce timing, and never touch a real Looper.

If you are already comfortable with coroutines, this is strictly more pleasant than the LiveData approach. If you are not, the ramp cost is real. TestDispatcher, advanceTimeBy, StandardTestDispatcher vs UnconfinedTestDispatcher are all things to learn.

Summary, which to pick

For new Android code in 2026, I default to StateFlow for state, SharedFlow or Channel for events, and I reach for collectAsStateWithLifecycle() in Compose. The kotlin flow vs livedata question for greenfield work is largely settled. Flow gives me operators, portability, and a better testing story, and the lifecycle boilerplate is a solved problem.

For existing Android only codebases that already ship LiveData through Room and Data Binding, I do not migrate proactively. LiveData is not deprecated, it continues to work, and the rewrite rarely produces user visible wins. I let migration happen opportunistically, when a file is already being touched for a real reason and the new shape fits Flow better.

The worst outcome is a half migrated codebase where every screen teaches a new engineer two patterns instead of one. Pick a direction per module and stay consistent.

Learn more

If you want the internals (why StateFlow conflates, how SharedFlow replays, what channelFlow and callbackFlow actually do differently, how flowOn inserts a Channel boundary, and how Dispatchers route continuations) those are the exact topics I cover in the coroutines and Flow chapters of Practical Kotlin Deep Dive. The companion guide at /en/guides/kotlin-coroutines-explained is a good free starting point, and /en/guides/suspend-function-internals goes deeper into the Continuation machinery that powers all of this.

As always, happy coding!

— Jaewoong (skydoves)

Frequently asked questions

Is Kotlin Flow a replacement for LiveData?â–¾

StateFlow and SharedFlow can replace almost every LiveData use case, and Google's own guidance now leads with Flow. But LiveData is not deprecated. If you have a stable Android-only codebase that already wires LiveData through Room and the Data Binding library, there is no technical reason to rewrite it. For new screens, I default to StateFlow.

What is the Flow equivalent of MutableLiveData?â–¾

MutableStateFlow. It holds a single current value, emits it to every new collector, and requires an initial value. The main behavioral differences are that StateFlow conflates fast updates (skips intermediate values), enforces value equality with distinctUntilChanged semantics, and has no concept of 'main thread only', you collect it on whatever dispatcher you choose.

Does StateFlow handle lifecycle automatically like LiveData?â–¾

No. StateFlow is a plain coroutine primitive, it knows nothing about Android lifecycles. You opt in explicitly with lifecycleScope.launch { repeatOnLifecycle(STARTED) { stateFlow.collect { ... } } }. The verbosity is the price of portability, the exact same StateFlow can be collected from a ViewModel, a Compose screen, a Kotlin Multiplatform iOS target, or a unit test.

How do I handle one-shot events with Flow?â–¾

Use Channel exposed as receiveAsFlow(), or MutableSharedFlow with replay = 0 and extraBufferCapacity >= 1. Both deliver each event exactly once to a single collector. LiveData is a poor fit for events because it re-delivers the latest value on every observer attachment, which is why patterns like SingleLiveEvent and Event wrappers exist. With Flow you do not need those hacks.

Is Flow harder to test than LiveData?â–¾

Arguably easier now. With runTest and Turbine, you can assert on an ordered stream of emissions without pulling in InstantTaskExecutorRule, without needing Observer boilerplate, and without fragile awaitValue helpers. The test is synchronous and deterministic under the TestDispatcher.

Does Flow have backpressure?â–¾

Flow handles backpressure through suspension, not buffers by default. If the collector is slow, the upstream emit() call suspends until the collector is ready. You opt into richer strategies with buffer(), conflate(), or collectLatest() depending on whether you want queueing, latest-only, or cancel-and-restart semantics. LiveData silently conflates on the main thread and has no knob.

Can I use Flow with Kotlin Multiplatform?â–¾

Yes. Flow lives in kotlinx.coroutines, which supports JVM, Android, iOS, JS, and native targets. LiveData lives in androidx.lifecycle and pulls in android.os classes, so it cannot cross the Android boundary. If any part of your codebase might share with iOS or a server module, Flow is the only realistic option.

Should I migrate an existing LiveData codebase to Flow?â–¾

Only when you get something concrete in return: a multiplatform module, a transformation LiveData can't express cleanly, a testing pain point, or Compose adoption where StateFlow integrates more naturally with collectAsStateWithLifecycle. Migration for migration's sake rarely pays for itself.