devsnack

Flows in Compose: Stopping the Collector Does Not Stop the Flow

collectAsStateWithLifecycle ends the screen's subscription and nothing else. The sharing policy that stops the upstream, the flow rebuilt on every recomposition, and the result that never opens because the app was in the background.

DevSnack15 Sep 2026 · 13 min

The credits counter in the corner of the generator screen polls your backend every thirty seconds. It is a small feature and it works. A week after launch, the request log shows a steady thirty-second heartbeat from phones whose owners left the app hours ago and never swiped it away. Some of them polled all night.

You find the problem, swap collectAsState for collectAsStateWithLifecycle as every guide says to, press Home, and watch the log. It keeps printing.

A Compose screen that shows a Flow has two subscriptions: the screen's to the ViewModel, and the ViewModel's to whatever produces the data. A lifecycle-aware collector ends the first one. It has no say over the second. Most of the flow bugs in a Compose app — the background polling, the list that flashes empty, the result screen that never opens, the paid request sent twice — are one of those two lifetimes being longer or shorter than the screen it belongs to.

Routes and arguments are the type-safe navigation post, and recomposition performance is a subject of its own. This one is about when a flow starts, when it stops, and what happens to a value emitted while nobody is looking.

collectAsState collects until the composition is gone

Pressing Home stops the Activity. It does not dispose the composition. The UI tree is still there, every effect in it is still running, and collectAsState — which is a coroutine launched from the composition — carries on collecting and writing each value into state that no frame is going to draw. The composition only goes away when the Activity is destroyed or the user navigates to another destination.

ui/generate/GenerateViewModel.kt
val credits: StateFlow<Int?> = flow {
    while (true) {
        emit(api.creditsRemaining())
        delay(30_000)
    }
}
    .onEach { Log.d("Credits", "polled") }
    .stateIn(viewModelScope, SharingStarted.Eagerly, null)
ui/generate/GenerateScreen.kt
val credits by viewModel.credits.collectAsState()

That polls in the background for two independent reasons, and removing either one alone changes nothing you can see in the log. That is why the fix in every guide looks like it did not work.

collectAsStateWithLifecycle ends the screen's subscription

The lifecycle-aware version lives in androidx.lifecycle:lifecycle-runtime-compose, not in Compose itself, so it is a dependency before it is a rename.

gradle/libs.versions.toml
[libraries]
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
ui/generate/GenerateScreen.kt
val credits by viewModel.credits.collectAsStateWithLifecycle()

It collects while the lifecycle is at least STARTED, stops when the screen leaves the foreground, and starts again when it comes back. On a StateFlow the screen reopens showing the current value immediately rather than a blank, because a state flow always has one. In an Android screen there is no case for the plain version.

Now press Home again. The collector stops, and the log keeps printing, because the thing doing the polling was never the collector.

The ViewModel decides whether the upstream stops

stateIn launches a coroutine in viewModelScope that collects the upstream flow and shares the result, and SharingStarted is the policy for when that coroutine runs. Eagerly runs it from the moment the ViewModel is created until it is cleared. Lazily waits for the first subscriber, then runs until it is cleared. Neither looks at the subscriber count ever again.

"Until the ViewModel is cleared" is much longer than it sounds. A ViewModel scoped to a navigation destination lives as long as that destination is on the back stack — so a screen the user navigated past keeps its upstream running underneath the screen they are actually looking at, with the app in the foreground and every collector in it lifecycle-aware.

ui/generate/GenerateViewModel.kt
val credits: StateFlow<Int?> = pollCredits()
    .stateIn(
        scope = viewModelScope,
        // Stop the upstream five seconds after the last collector
        // leaves. A rotation takes less than that, so it does not
        // restart the poll. Pressing Home takes longer, so it does.
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = null,
    )

WhileSubscribed is the policy that matches a screen. The timeout exists for configuration changes: rotating the phone destroys the Activity and builds a new one, and for a moment nothing is collecting. With a timeout of zero, every rotation would cancel the poll and fire a fresh request. The last value is kept by default, so a user returning after the upstream stopped sees the cached number at once while the restarted flow fetches a new one.

One consequence to know about: with no subscriber, the state flow's value is frozen at whatever it was when the upstream stopped. Code in the ViewModel that reads credits.value from a button handler is reading a cache, not asking the backend.

stateIn inside a function is a leak with a return type

The shape below looks like a harmless variation on the property above. It is the most expensive line in this post.

ui/generate/GenerateViewModel.kt
// A new StateFlow, and a new sharing coroutine in viewModelScope,
// every time anything calls this. With Eagerly, each of those
// coroutines polls until the ViewModel is cleared.
fun credits(): StateFlow<Int?> =
    pollCredits().stateIn(viewModelScope, SharingStarted.Eagerly, null)

Every call starts another copy, and none of the earlier copies stop. Call it from a composable and it runs once per recomposition, which turns one polling loop into one for every time that screen has recomposed since it opened. Declare a shared flow once, as a property, and let every collector share the same one — that is what the word means.

A flow built during composition is new every recomposition

The same mistake has a quieter form that needs no stateIn at all: calling a function that returns a flow from inside the composable.

ui/saved/SavedScreen.kt
@Composable
fun SavedScreen(viewModel: SavedViewModel, network: SocialNetwork) {
    // Room returns a new Flow object on every DAO call.
    val posts by viewModel.observePosts(network)
        .collectAsStateWithLifecycle(initialValue = emptyList())
    …
}

Both collectors key their internal state on the flow instance they are called on. A new instance is a new key, so each recomposition throws away the collected list, resets to emptyList(), and starts the query again. The query then emits, the emission changes state, the change recomposes the screen, and the screen builds another flow — which is how a list ends up flickering between empty and full while the database runs the same query over and over.

remember(network) { … } around the call stops the loop, and is a patch. The remembered flow is gone after a rotation, so the screen still flashes empty and re-queries on every configuration change, and a second screen observing the same data gets its own query rather than sharing one.

The parameter is state, and it belongs in SavedStateHandle

The fix is to stop passing the parameter to a function and make it an input the ViewModel owns. The flow is then built exactly once, and a change of filter switches what it reads rather than replacing it.

ui/saved/SavedViewModel.kt
@OptIn(ExperimentalCoroutinesApi::class)
class SavedViewModel(
    private val savedStateHandle: SavedStateHandle,
    dao: GeneratedPostDao,
) : ViewModel() {

    // Survives process death, not just rotation. A plain
    // MutableStateFlow comes back as its default after the system
    // kills the app in the background and the user returns.
    private val network = savedStateHandle.getStateFlow(KEY_NETWORK, SocialNetwork.ALL)

    val posts: StateFlow<List<GeneratedPost>> = network
        .flatMapLatest { dao.observeByNetwork(it) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    fun selectNetwork(value: SocialNetwork) {
        savedStateHandle[KEY_NETWORK] = value
    }

    private companion object {
        const val KEY_NETWORK = "network"
    }
}

flatMapLatest cancels the previous query as soon as the filter changes, so a fast series of taps never delivers an older result after a newer one. It is still marked experimental in kotlinx.coroutines, which is what the opt-in is for; it has been stable in practice for years.

SavedStateHandle is also where navigation arguments already are. With Navigation Compose, a destination's route arguments are readable from the handle of the ViewModel scoped to it, so an id passed in a route never needs to travel through the composable to reach the ViewModel at all.

An event nobody is collecting is delivered to nobody

Generating a menu description takes ten or fifteen seconds, which is long enough for a user to switch to Instagram and come back. The common way to open the result screen when it finishes is an event stream.

ui/generate/GenerateViewModel.kt
private val _events = MutableSharedFlow<GenerateEvent>()
val events = _events.asSharedFlow()

fun generate(dish: DishInput) = viewModelScope.launch {
    val id = repository.generateAndSave(dish)
    // The screen collects with the lifecycle, so while the user is
    // in another app there is no collector. With no replay and no
    // subscribers, emit returns at once and the event is dropped.
    _events.emit(GenerateEvent.OpenResult(id))
}

Lifecycle-aware collection is correct, and it is exactly what makes this break: "no collector" now describes every moment the app is in the background. The user returns to the form, the spinner has gone, and the result they paid a request for is saved somewhere they are not taken to. Swapping in a StateFlow for the event fails the other way — the value is still there after a rotation, and the result screen opens twice. A Channel buffers until someone receives, which closes most of the gap but not the moment where the value has been received and the collector is cancelled before acting on it.

The guidance in Android's own architecture docs is to stop calling it an event. "There is a result the user has not been shown yet" is a fact about the screen, and facts are state.

ui/generate/GenerateViewModel.kt
data class GenerateUiState(
    val isGenerating: Boolean = false,
    // Set when a result is ready, cleared when the UI has acted on it.
    // Survives the user leaving, and a rotation cannot open it twice.
    val pendingResultId: Long? = null,
)

fun generate(dish: DishInput) = viewModelScope.launch {
    _state.update { it.copy(isGenerating = true) }
    try {
        val id = repository.generateAndSave(dish)
        _state.update { it.copy(pendingResultId = id) }
    } finally {
        _state.update { it.copy(isGenerating = false) }
    }
}

fun resultOpened() = _state.update { it.copy(pendingResultId = null) }
ui/generate/GenerateScreen.kt
val state by viewModel.state.collectAsStateWithLifecycle()

state.pendingResultId?.let { id ->
    LaunchedEffect(id) {
        onOpenResult(id)
        viewModel.resultOpened()
    }
}

If the user is away when the result lands, the id waits in state and the navigation happens the moment the screen is started again. Error handling takes the same shape — a field describing the failure, cleared when it has been shown — and is left out here to keep the example short.

LaunchedEffect(Unit) runs once per composition, not once per screen

The last lifetime bug runs the other way: work that is started by the composition and so restarts whenever the composition does.

ui/result/ResultScreen.kt
@Composable
fun ResultScreen(viewModel: ResultViewModel, dishName: String) {
    // Rotating builds a new composition, and so does coming back to
    // this destination from the next one. Each is another request.
    LaunchedEffect(Unit) { viewModel.generate(dishName) }
    …
}

Unit as a key means the effect never restarts within a composition. It says nothing about the composition itself being thrown away, which happens on every rotation and every time the user navigates forward and back. For most effects that is harmless. For an effect that sends a billed request to a model, it is a second invoice for the same caption.

ui/result/ResultViewModel.kt
class ResultViewModel(
    savedStateHandle: SavedStateHandle,
    private val repository: GenerationRepository,
) : ViewModel() {

    private val dishName: String = checkNotNull(savedStateHandle["dishName"])

    // Once per ViewModel, which outlives the rotation and stays
    // alive while this destination is on the back stack.
    init {
        generate()
    }

    // Every other request is a tap on Regenerate, which is a
    // decision the user made rather than a side effect of a frame.
    fun regenerate() = generate()

    private fun generate() { … }
}

One edge remains. After process death the ViewModel is new as well, so its init runs again. If a repeated request is expensive enough to matter, check the local database for a result saved under that input before generating — the Room row outlives every lifetime in this post.

Watch the upstream, not the screen

All of this can be checked in a minute, and it is worth doing on every flow that touches the network, because the screen looks identical whether the upstream stops or not.

ui/generate/GenerateViewModel.kt
val credits: StateFlow<Int?> = pollCredits()
    .onStart { Log.d("Credits", "upstream started") }
    // Runs on cancellation too, which is the case being tested.
    .onCompletion { Log.d("Credits", "upstream stopped") }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)

Press Home and wait: five seconds later the log should say it stopped. Rotate the phone: it should say nothing. Navigate to another screen, wait longer than the timeout, and come back: it should stop and start once each. If upstream stopped never appears, then somewhere between the database or network and the screen, a ViewModel is sharing eagerly or a collector is outliving the screen it was drawn for — and the request log was telling you so all along.

AI Menu & Post Generator — $11

A native Compose app on MVVM, Hilt and Room, with OpenAI generation screens and saved results — the whole project to read and change.

View on CodeCanyon