23 March 2026
Collecting flows in Android UI is deceptively simple to get wrong. You call flow.collect { } inside a coroutine, it works in development, you ship it, and then your app drains 15% battery in the background because the collection never stopped. Iâve debugged this exact issue in a production app â a location flow was collecting GPS updates even when the app was backgrounded, burning through battery and generating analytics events that nobody would ever see.
The core problem is that flows donât know about Android lifecycles. A Flow emits values and a coroutine collects them. The coroutine doesnât care if the Activity is visible, backgrounded, or halfway through being destroyed. It keeps collecting until the coroutine is cancelled or the flow completes. That gap between âcoroutine is aliveâ and âUI is visibleâ is where the bugs live. And the right fix depends on whether youâre building with Views or Compose, because the two systems handle lifecycle awareness differently.
The most common first attempt looks like this:
class OrdersActivity : AppCompatActivity() {
private val viewModel: OrdersViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
viewModel.ordersFlow.collect { orders ->
updateOrdersList(orders)
}
}
}
}
This works â technically. The collection starts in onCreate, and lifecycleScope cancels the coroutine when the Activity is destroyed. But hereâs what it doesnât do: it doesnât stop collecting when the app goes to the background. The user presses Home, the Activity moves to STOPPED, but the coroutine is still alive. The flow keeps emitting, the collector keeps processing, and if that flow is backed by a network call or database query, youâre wasting CPU, battery, and bandwidth on updates the user canât see.
It gets worse with view updates. If the flow emits while the Activity is stopped, youâre calling updateOrdersList on views that are technically still around but not visible. For Fragments, this is even more dangerous â the Fragmentâs view can be destroyed while the Fragment itself survives (think back stack), so you end up with a null view reference and a crash.
The fundamental issue is that lifecycleScope.launch ties the coroutine to the DESTROYED lifecycle event. But for UI updates, you want to tie collection to visibility â start when the UI is visible (STARTED), stop when itâs not. Thatâs a tighter window, and itâs the window that matters.
repeatOnLifecycle is the recommended approach for lifecycle-aware flow collection in View-based UI. Itâs a suspend function from androidx.lifecycle:lifecycle-runtime-ktx that takes a Lifecycle.State and a suspend block. When the lifecycle reaches the target state, it launches the block in a new coroutine. When the lifecycle drops below that state, it cancels the coroutine. When the lifecycle comes back up, it launches the block again in a fresh coroutine. This start-cancel-restart cycle continues until the lifecycle is destroyed.
Hereâs the Activity pattern:
class OrdersActivity : AppCompatActivity() {
private val viewModel: OrdersViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.ordersFlow.collect { orders ->
updateOrdersList(orders)
}
}
}
}
}
And the Fragment pattern, which has one important difference â use viewLifecycleOwner.lifecycleScope instead of lifecycleScope:
class OrdersFragment : Fragment() {
private val viewModel: OrdersViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.ordersFlow.collect { orders ->
binding.ordersList.adapter = OrdersAdapter(orders)
}
}
}
}
}
In Fragments, the Fragmentâs lifecycle and the Fragmentâs view lifecycle are different things. A Fragment on the back stack is still alive (lifecycleScope is active), but its view is destroyed. If you use lifecycleScope instead of viewLifecycleOwner.lifecycleScope, youâll collect flow emissions and try to update a view that no longer exists. Iâve seen this cause NullPointerException crashes in production â the binding is null because onDestroyView already ran.
Why STARTED and not RESUMED? STARTED covers the visible-but-not-focused window. When a dialog or a transparent Activity appears on top, your Activity drops from RESUMED to STARTED but is still partially visible. If you used RESUMED, the flow collection would stop every time a dialog pops up, which means your UI state goes stale behind the dialog. STARTED keeps the data flowing as long as the user can see the screen. Googleâs official guidance recommends STARTED as the default for flow collection, and after hitting the dialog edge case myself, I agree.
When you need to collect multiple flows, launch each in a separate coroutine inside the repeatOnLifecycle block. The block provides a CoroutineScope, so you can call launch directly:
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.ordersFlow.collect { orders ->
updateOrdersList(orders)
}
}
launch {
viewModel.notificationsFlow.collect { count ->
updateBadge(count)
}
}
}
}
Both collections start and stop together, tied to the same lifecycle. This is cleaner than calling flowWithLifecycle twice, which Iâll cover next.
flowWithLifecycle is a convenience extension on Flow that internally wraps repeatOnLifecycle. It returns a new flow that only emits when the lifecycle is at least in the target state. Under the hood, it launches a repeatOnLifecycle block and re-emits each value into a new flow.
viewLifecycleOwner.lifecycleScope.launch {
viewModel.ordersFlow
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.collect { orders ->
updateOrdersList(orders)
}
}
This is slightly less boilerplate for a single flow. But the moment you need two or more flows, flowWithLifecycle becomes awkward. Each call creates its own internal repeatOnLifecycle, which means separate coroutines, separate lifecycle observations, and more overhead. For multiple flows, use repeatOnLifecycle directly with separate launch blocks â itâs more efficient because thereâs a single lifecycle observer managing all the coroutines.
I use flowWithLifecycle only when Iâm chaining operators on a single flow and want to keep the pipeline fluent. For anything more complex, repeatOnLifecycle is the better choice.
In Compose, the API is different but the problem is the same. Compose provides collectAsState() to convert a Flow into a Compose State that triggers recomposition on new values. Itâs clean and works great â except it doesnât respect the Android lifecycle.
@Composable
fun OrdersScreen(viewModel: OrdersViewModel = viewModel()) {
// Does NOT respect lifecycle â keeps collecting in background
val orders by viewModel.ordersFlow.collectAsState(initial = emptyList())
LazyColumn {
items(orders) { order ->
OrderItem(order)
}
}
}
collectAsState starts collecting when the composable enters composition and stops when it leaves composition. But composition is not the same as visibility. A composable stays in composition when the app is backgrounded â itâs just not drawing. So the flow keeps emitting, the state keeps updating, and youâre doing work for a UI nobody can see.
The fix is collectAsStateWithLifecycle() from androidx.lifecycle:lifecycle-runtime-compose. This function does exactly what the name says â it collects the flow as a Compose State, but it also observes the lifecycle. When the lifecycle drops below STARTED, collection stops. When it comes back, collection restarts.
@Composable
fun OrdersScreen(viewModel: OrdersViewModel = viewModel()) {
// Respects lifecycle â stops collecting when backgrounded
val orders by viewModel.ordersFlow.collectAsStateWithLifecycle(
initialValue = emptyList()
)
LazyColumn {
items(orders) { order ->
OrderItem(order)
}
}
}
One function call, one line of difference, and your app stops wasting resources in the background. Internally, collectAsStateWithLifecycle uses repeatOnLifecycle with Lifecycle.State.STARTED as the default minActiveState. You can override this if needed â collectAsStateWithLifecycle(initialValue = ..., minActiveState = Lifecycle.State.RESUMED) â but STARTED is the right default for the same reasons I described above.
The difference matters in practice. In an app I worked on, switching from collectAsState to collectAsStateWithLifecycle for a real-time pricing flow reduced background CPU usage by roughly 40% because we stopped processing WebSocket messages when the screen wasnât visible. Thatâs battery life users notice.
Using launchWhenStarted (deprecated). Before repeatOnLifecycle existed, the recommended pattern was lifecycleScope.launchWhenStarted. This suspends the coroutine when the lifecycle drops below STARTED instead of cancelling it. The difference is critical â suspending means the coroutine is still alive, holding references to everything in its closure. The upstream flow producer keeps running too, buffering emissions that pile up in memory. repeatOnLifecycle cancels the coroutine entirely, so the upstream flow stops producing. Google deprecated launchWhenStarted for exactly this reason. If you see it in your codebase, replace it.
Using collectAsState instead of collectAsStateWithLifecycle. This is the Compose equivalent of the same mistake. collectAsState is fine for flows that are truly UI-local (a snapshotFlow tracking scroll position, for example), but for any flow backed by network, database, or sensor data, use collectAsStateWithLifecycle. The lint rule FlowOperatorInvokedInComposition wonât catch this â you have to know the difference.
Collecting in onCreate without repeatOnLifecycle. I covered this earlier, but itâs worth repeating because itâs the single most common flow collection bug Iâve seen in code reviews. lifecycleScope.launch { flow.collect { } } runs until the Activity or Fragment is destroyed. It does not stop when the app goes to the background. Wrap every UI flow collection in repeatOnLifecycle(Lifecycle.State.STARTED).
Forgetting viewLifecycleOwner in Fragments. Using lifecycleScope in a Fragmentâs onViewCreated ties the collection to the Fragmentâs lifecycle, not the viewâs lifecycle. When the Fragment is on the back stack, its view is destroyed but the Fragment lives on. The coroutine keeps running, collects a new value, and tries to access a binding thatâs already null. Always use viewLifecycleOwner.lifecycleScope and viewLifecycleOwner.repeatOnLifecycle in Fragments.
Question 1. Whatâs the difference between lifecycleScope.launch { flow.collect { } } and wrapping the collection in repeatOnLifecycle(Lifecycle.State.STARTED)?
Wrong: Theyâre the same â both stop collecting when the Activity is destroyed.
Correct: lifecycleScope.launch keeps collecting even when the app is in the background, stopping only when the lifecycle reaches DESTROYED. repeatOnLifecycle(STARTED) cancels the collecting coroutine when the lifecycle drops below STARTED (the app is backgrounded) and relaunches it when the lifecycle reaches STARTED again. This saves resources because the upstream flow stops producing while the UI isnât visible.
Question 2. In Compose, why should you use collectAsStateWithLifecycle() instead of collectAsState()?
Wrong: collectAsStateWithLifecycle() is faster because it uses an optimized internal collector.
Correct: collectAsState() keeps collecting the flow even when the app is in the background because itâs tied to composition, not the Android lifecycle. collectAsStateWithLifecycle() uses repeatOnLifecycle internally with Lifecycle.State.STARTED by default, so it stops collection when the app is backgrounded and restarts when it becomes visible again. For flows backed by network, database, or sensor work, this prevents wasted CPU, battery, and memory.
Build a DashboardFragment that collects three flows from a DashboardViewModel: ordersFlow (a StateFlow<List<Order>>), revenueFlow (a Flow<Revenue>), and alertsFlow (a SharedFlow<Alert>). Use viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) to collect all three flows with proper lifecycle awareness. Each flow should update a different part of the UI. Then convert the Fragment to a Compose screen using collectAsStateWithLifecycle() for each flow. Verify that backgrounding the app stops all three collections in both implementations.
Thanks for reading!