Sharing Flows & Lifecycle-Safe Collection Explained

KOTLIN › Flow

Two problems sit at the seam between a cold flow and a screen, and they are the same problem twice.

**Upstream:** a cold flow re-runs its producer for every collector. Two collectors means two database queries, and a configuration change means the query restarts from scratch. You want **one** upstream shared by everyone.

**Downstream:** a collector runs until its coroutine is cancelled. Collect in an Activity and the collection carries on while the app is backgrounded, updating views nobody can see. You want the collection to **stop when the screen stops**.

// Both problems, in the code people write first
class UserViewModel(repo: UserRepository) : ViewModel() {
    val users: Flow<List<User>> = repo.observeUsers()   // cold: per-collector query
}

// In the Fragment
lifecycleScope.launch {
    viewModel.users.collect { render(it) }              // never stops
}

The two fixes pair up neatly. stateIn and shareIn solve the upstream half by making one shared, hot stream. repeatOnLifecycle and collectAsStateWithLifecycle solve the downstream half by tying the collection to a lifecycle state.

They also have to agree with each other, which is where SharingStarted.WhileSubscribed comes in: if the downstream stops during a rotation, the upstream should not necessarily stop with it. Getting that handshake right is most of this topic.

Screen state rarely starts life as a StateFlow. More often it starts as a cold flow, a repository method returning Flow<Data>, and you convert it into something hot for the UI to collect. Two operators do that conversion, and which one you reach for follows the same state-versus-events split.

stateIn produces a StateFlow. Because a StateFlow must always have a current value, stateIn requires you to supply one.

val uiState: StateFlow<UiState> = repository.dataFlow()
    .map { UiState.Success(it) }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = UiState.Loading
    )

shareIn produces a SharedFlow instead, and needs no initial value, since it's modelling events rather than a current state.

val shared: SharedFlow<Data> = repository.expensiveDataFlow().shareIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(),
    replay = 0
)

Both multicast a single upstream producer across every collector, so the repository's expensive work runs once no matter how many parts of the UI are observing it.

Both stateIn and shareIn take a started parameter that controls when the upstream producer actually runs. SharingStarted.Eagerly starts it immediately and keeps it alive for as long as the scope lives, regardless of how many collectors show up. SharingStarted.WhileSubscribed(stopTimeoutMillis) is demand-driven instead: it starts on the first subscriber and stops stopTimeoutMillis after the last one unsubscribes.

WhileSubscribed(5_000) is the common choice on Android because a configuration change briefly drops the collector count to zero while the old Activity tears down and the new one starts collecting again. Without that five-second grace period, the brief gap would tear down and restart an expensive upstream producer on every rotation. Eagerly ignores subscriber count entirely, which is right for work that should run for the whole scope's life regardless of who's watching, and wrong for anything you only want running while it's actually needed.

SharingStarted.WhileSubscribed(5_000) is the value you will see everywhere, and interviewers ask where the number comes from.

It is the stopTimeoutMillis: how long the upstream keeps running after the **last** subscriber disappears. The number is chosen to sit between two Android lifecycle events:

- A **configuration change** takes well under 5 seconds. The old Fragment unsubscribes, the new one subscribes, and because the timeout has not elapsed the upstream never stopped. No re-query, no flicker, no reload spinner on rotation. - **Backgrounding** the app lasts longer than 5 seconds in any realistic case, so the upstream does stop, and you are not holding a database cursor or a network socket open behind a screen the user has left.

val users = repo.observeUsers()
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

There is a second parameter people forget:

SharingStarted.WhileSubscribed(
    stopTimeoutMillis = 5_000,
    replayExpirationMillis = Long.MAX_VALUE,   // default: keep the cached value forever
)

replayExpiration controls how long the **cached value** survives after the upstream stops. The default keeps it indefinitely, so returning to a screen shows the last known state immediately rather than a loading spinner, and then refreshes. Set it to zero and the replay cache is dropped when sharing stops, so a returning subscriber gets the initial value and waits for fresh data.

The pairing to remember: stopTimeout is about the **producer**, replayExpiration is about the **cache**.

stateIn has two overloads and choosing the wrong one causes a bug people find hard to explain.

The one you have seen takes a scope and returns immediately with an initial value. There is also a **suspending** overload with no initial value:

// Non-suspending: returns at once, initial value required
val a: StateFlow<List<User>> =
    flow.stateIn(viewModelScope, WhileSubscribed(5_000), emptyList())

// Suspending: waits for the first upstream value, no initial needed
val b: StateFlow<List<User>> = flow.stateIn(viewModelScope)

The suspending version starts sharing **eagerly** and suspends until the first value arrives, so it can only be called from a coroutine and it will hang if the upstream never emits. It is occasionally right for initialisation code, and almost never right for a property on a ViewModel.

The related trap is where you call it. A stateIn inside a function creates a **new** shared flow every call:

// Bug: a fresh upstream per call, so nothing is actually shared
fun users(): StateFlow<List<User>> =
    repo.observeUsers().stateIn(viewModelScope, WhileSubscribed(5_000), emptyList())

// Correct: one shared instance, created once
val users: StateFlow<List<User>> =
    repo.observeUsers().stateIn(viewModelScope, WhileSubscribed(5_000), emptyList())

The first version compiles, type-checks, and defeats the entire purpose: each caller gets its own upstream, and you are back to one query per collector with extra machinery. If the flow needs a parameter, cache the result per key rather than rebuilding it.

Collecting a flow naively in an Activity or View, lifecycleScope.launch { vm.state.collect { render(it) } } with no further wrapping, keeps collecting for as long as lifecycleScope itself is alive. That spans the whole Activity, so it keeps running while the app is backgrounded, wasting work and potentially rendering into views that are no longer on screen.

repeatOnLifecycle(STARTED) fixes this. It launches a fresh coroutine once the lifecycle reaches STARTED, and fully **cancels** it, not merely pauses it, the moment the lifecycle drops to STOPPED. Cancelling tears down the upstream collection entirely, then a return to STARTED restarts it cleanly.

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        vm.state.collect { render(it) }
    }
}

The older launchWhenStarted and launchWhenResumed look similar but only **suspend** the coroutine below their target state, they don't cancel it, so the upstream producer, and anything it's doing such as network polling, keeps running in the background regardless. That gap is exactly why both are deprecated in favor of repeatOnLifecycle.

launchWhenStarted is the deprecated predecessor to repeatOnLifecycle, and the reason it was deprecated is worth being able to state precisely, because it is a favourite interview question.

// Deprecated
lifecycleScope.launchWhenStarted { vm.state.collect { render(it) } }

// Correct
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) { vm.state.collect { render(it) } }
}

launchWhenStarted **suspends** the coroutine when the lifecycle drops below STARTED. It does not cancel it. So:

- The collector stops processing values, which looks correct. - But the collection is still active, so the **upstream keeps producing**. A database cursor stays open, a network poll keeps polling, a location listener keeps burning battery, for the entire time the app is backgrounded. - With WhileSubscribed, the subscriber never leaves, so the timeout never fires and the upstream never stops either.

repeatOnLifecycle **cancels** the block on the way down and runs it again on the way up. The subscription genuinely ends, which is what lets WhileSubscribed do its job.

That is the pairing to state as one sentence: **repeatOnLifecycle cancels the subscription, which is what allows WhileSubscribed to stop the producer.** launchWhenStarted breaks the handshake at the downstream end, so the upstream optimisation upstream can never engage.

Note also that repeatOnLifecycle is itself a suspend function, so it must be inside a launch, and everything after it in that coroutine only runs once the lifecycle is destroyed.

Compose has the same problem and the same fix, under different names. Plain collectAsState() keeps collecting in the background no matter what the composable's visibility is, the same leak repeatOnLifecycle solves for View-based screens. collectAsStateWithLifecycle(), from the lifecycle-runtime-compose artifact, is the lifecycle-aware replacement: it stops collecting once the composable's lifecycle drops below STARTED and resumes when it returns.

@Composable
fun MyScreen(vm: MyViewModel = viewModel()) {
    val state by vm.state.collectAsStateWithLifecycle()
    MyContent(state)
}

The underlying rule is identical to the View case: don't keep an upstream StateFlow collection alive, and doing whatever work backs it, while the screen isn't visible.

Compose adds one more source of confusion: collectAsState and collectAsStateWithLifecycle look interchangeable and are not.

// Stops only when the composable leaves composition
val state by viewModel.state.collectAsState()

// Also stops when the lifecycle drops below STARTED
val state by viewModel.state.collectAsStateWithLifecycle()

A backgrounded Activity does **not** leave composition. The composables are still there, still subscribed, so collectAsState keeps collecting the whole time the app is in the background. It is the Compose equivalent of the launchWhenStarted problem, and it is the default people reach for because it has the shorter name and lives in the core library.

collectAsStateWithLifecycle lives in androidx.lifecycle:lifecycle-runtime-compose and wraps the same repeatOnLifecycle machinery, so it participates properly in the WhileSubscribed handshake.

The guidance is simple: **on Android, default to collectAsStateWithLifecycle.** Use plain collectAsState only for a flow with no external subscription cost, or in multiplatform code where the lifecycle-aware variant is not available.

One detail people miss: both need an initial value, because Compose has to render something on the first frame. A StateFlow supplies its own, but a plain Flow does not:

val items by viewModel.itemsFlow.collectAsStateWithLifecycle(initialValue = emptyList())

which is a good reason to expose StateFlow from a ViewModel rather than a bare Flow: the initial value becomes the ViewModel's decision rather than each screen's.

Where the lifecycle boundary sits is a design question, and getting it wrong produces the two most common Flow bugs on Android.

**The boundary belongs at the UI layer, not in the ViewModel.** A ViewModel outlives configuration changes on purpose, so it must not care whether a screen is currently visible. It exposes a StateFlow and lets WhileSubscribed handle the rest.

// ViewModel: no lifecycle awareness at all
val state: StateFlow<UiState> = repo.observe()
    .map(::toUiState)
    .stateIn(viewModelScope, WhileSubscribed(5_000), UiState.Loading)

// Fragment: the only place that knows about lifecycle
lifecycleScope.launch {
    repeatOnLifecycle(STARTED) { viewModel.state.collect(::render) }
}

**Bug one: collecting in onCreate without repeatOnLifecycle.** lifecycleScope.launch { collect { } } is cancelled at onDestroy, which for a Fragment is far too late: the view is destroyed at onDestroyView, so render touches a released binding and crashes, or leaks the view for the rest of the Fragment's life. Use viewLifecycleOwner.lifecycleScope in a Fragment, not lifecycleScope.

**Bug two: one repeatOnLifecycle per flow.** Each call is its own coroutine and its own overhead. Collect several flows inside a single block:

viewLifecycleOwner.lifecycleScope.launch {
    repeatOnLifecycle(STARTED) {
        launch { vm.state.collect(::render) }
        launch { vm.events.collect(::handle) }
    }
}

The whole topic as one decision tree.

**Do multiple collectors need to share one upstream?** Yes: stateIn if there is always a current value (screen state), shareIn if there is not (events). No: leave it cold, and let each collector run its own producer.

**When should the upstream run?** - Eagerly: immediately, forever. For a small, cheap source you always want warm. - Lazily: from the first subscriber, forever after. Rarely the right answer. - WhileSubscribed(5_000): from the first subscriber, stopping 5 seconds after the last leaves. The default for anything backed by a real resource.

**How should the UI collect?** - Views: repeatOnLifecycle(STARTED) inside viewLifecycleOwner.lifecycleScope.launch. - Compose: collectAsStateWithLifecycle(). - Never: launchWhenStarted, or plain collectAsState on Android.

The sentence that ties it together, and the one worth having ready: **repeatOnLifecycle cancels the subscription so that WhileSubscribed can stop the producer.** Both halves are needed. Lifecycle-aware collection with SharingStarted.Eagerly still runs the producer in the background. WhileSubscribed with launchWhenStarted never sees the subscriber leave, so it never stops either. The optimisation only exists when both ends agree.

The failure interviewers listen for is treating these as two unrelated best practices rather than as two halves of one handshake.

Back to Sharing Flows & Lifecycle-Safe Collection