Skip to content
All posts

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.

MFKAPPS 5 min read

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:

  • viewModelScope lives as long as the ViewModel — it survives configuration changes (rotation, dark-mode toggle) and is cancelled only when the ViewModel is 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 specifically repeatOnLifecycle(Lifecycle.State.STARTED) inside it, is scoped to the Activity or Fragment view lifecycle. Use it for anything that should genuinely stop when the screen isn’t visible — most commonly, collecting a Flow to 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.