Structured concurrency on Android in 2026: scopes, cancellation, and the leaks they prevent
A practical guide to Kotlin structured concurrency on Android — viewModelScope vs lifecycleScope, why cancellation propagates, and the coroutine leaks it quietly rules out.
Most coroutine bugs I’ve debugged on Android weren’t about suspend functions doing the wrong thing — they were about a coroutine outliving the thing that started it. A network call finishes after its screen is gone and crashes trying to update a destroyed view. A Flow collector keeps running in the background, burning battery for a screen nobody’s looking at. Structured concurrency is the discipline that rules these out by construction, not by remembering to clean up — and understanding what it actually guarantees is worth more than memorizing which Scope to use where.
The core idea: a coroutine can’t outlive its scope
Every coroutine in Kotlin belongs to a CoroutineScope, and that scope defines its lifetime. Launch a coroutine inside a scope, and it becomes a child of that scope’s job. Cancel the scope, and every child — no matter how deeply nested, no matter how many async calls it fanned out into — gets cancelled with it. This is the entire guarantee: you can’t accidentally leak a coroutine outside the scope that owns it, because the language doesn’t give you a way to.
class BudgetViewModel(private val repo: TransactionRepository) : ViewModel() {
fun refreshMonthlyTotal() {
viewModelScope.launch {
val total = repo.computeMonthlyTotal() // suspends here
_monthlyTotal.value = total
}
}
}
If the user navigates away and the ViewModel gets cleared mid-computeMonthlyTotal(), viewModelScope is cancelled automatically, and the coroutine above stops — it never reaches the line that writes to _monthlyTotal. No manual flag to check, no isActive guard needed before the write, because the coroutine simply doesn’t resume after cancellation reaches a suspension point.
viewModelScope, lifecycleScope, and picking the right one
Android gives you two scopes tied to component lifetimes, and picking the wrong one is the single most common structured-concurrency mistake I see:
viewModelScopelives as long as theViewModel— it survives configuration changes (rotation, dark-mode toggle) and is cancelled only when theViewModelis cleared, typically when the user navigates away for good. Use it for work whose result the screen still cares about after a rotation: loading data, writing to a repository, computing a derived value.lifecycleScope, and specificallyrepeatOnLifecycle(Lifecycle.State.STARTED)inside it, is scoped to theActivityorFragmentview lifecycle. Use it for anything that should genuinely stop when the screen isn’t visible — most commonly, collecting aFlowto update UI:
class ReminderListFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.reminders.collect { reminders ->
adapter.submitList(reminders)
}
}
}
}
}
Collecting directly in viewModelScope instead would keep the collector — and every Room query it triggers — running while the app is backgrounded, since viewModelScope doesn’t know the view is gone. That’s not a leak in the sense of a crash; it’s a slower, quieter cost: wasted CPU, wasted battery, and a Flow re-emitting into a UI nobody can see. repeatOnLifecycle is the fix precisely because it re-derives a fresh scope tied to STARTED, cancelling and restarting the collection as the lifecycle moves in and out of that state.
Structured concurrency makes error handling honest
Before structured concurrency, a common pattern was firing off a bare coroutine with GlobalScope.launch — unscoped, unowned, and outside any parent’s supervision. If it threw, the exception went nowhere useful, and if the screen was gone by the time it finished, there was nothing left to catch the crash. Structured concurrency forces every coroutine to have a parent, which means every exception has somewhere defined to propagate to: up through the job hierarchy to whatever installed a CoroutineExceptionHandler, or to the scope’s own cancellation if nothing did.
private val handler = CoroutineExceptionHandler { _, throwable ->
_uiState.value = UiState.Error(throwable.message)
}
fun syncPantryFromReceipt(bitmap: Bitmap) {
viewModelScope.launch(handler) {
val items = ocrEngine.extract(bitmap) // may throw
repo.insertAll(items)
}
}
This is the pattern behind how Stocky handles a failed receipt scan — the OCR step can throw for a dozen reasons (bad lighting, an unreadable font, a receipt format it’s never seen), and structured concurrency guarantees that failure surfaces to exactly one place instead of silently vanishing into a fire-and-forget coroutine.
async/await: cancellation runs both ways
coroutineScope { } and async builders extend the same guarantee to sibling coroutines: if one child of a coroutineScope throws, every other child gets cancelled too, and the exception propagates out only after all of them have wound down. This matters whenever you fan out concurrent work that only makes sense together:
suspend fun loadDashboard(): DashboardState = coroutineScope {
val totalDeferred = async { repo.computeMonthlyTotal() }
val trendDeferred = async { repo.computeSpendingTrend() }
DashboardState(totalDeferred.await(), trendDeferred.await())
}
If computeSpendingTrend() throws, computeMonthlyTotal() gets cancelled too — you never end up with half a dashboard silently rendered from a request that partially failed. Without structured concurrency, that second async would keep running after the first one crashed, its result awaited by nothing, its work wasted.
The takeaway
Structured concurrency isn’t a style preference — it’s what turns “did I forget to cancel this” from a runtime bug into a compile-time impossibility. The rule that matters in practice: launch inside the scope that matches how long the result should be needed, not the scope that happens to be nearby. viewModelScope for work the screen still cares about after rotation, lifecycleScope with repeatOnLifecycle for anything that should stop when the view isn’t visible, and a CoroutineExceptionHandler wherever a failure needs somewhere defined to go instead of nowhere at all.
// Related reading
More from the journal
The Android In-App Review API: asking for a rating without being annoying
A practical guide to Google's In-App Review API — how it actually works, where to trigger it, and why the usual 'rate us' popup is quietly hurting your Play Store rating.
Android app shortcuts and Quick Settings Tiles: logging water without opening the app
A practical guide to Android's dynamic ShortcutManager API and TileService — how to let a one-tap action skip the app entirely, with the pitfalls that trip most implementations.
Foreground service types on Android in 2026: picking the one your feature actually qualifies for
A practical guide to Android's foreground service type restrictions — dataSync, mediaPlayback, specialUse, shortService — and how to pick the right one without getting killed or rejected.