6 min read

Structured Concurrency Is a Feature, Not a Formality

Most coroutine bugs aren't about threads — they're about lifetimes. A senior tour of structured concurrency: scopes, cancellation, and the myths that cause leaks.

KotlinCoroutinesAndroid
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

Coroutines made asynchronous Kotlin readable. But "we use coroutines" and "we use coroutines safely" are very different claims. Almost every coroutine bug I've chased in production came down to one thing: lifetime. Work that outlived the screen that launched it, or work that failed silently and took nothing down with it. Structured concurrency is the answer to both — and it's worth understanding as a guarantee, not a ritual.

The guarantee

Structured concurrency means every coroutine has a parent, and a parent scope does not complete until all of its children have completed or been cancelled. That's it — but it's powerful. It turns "background work" from a set of loose threads you have to track by hand into a tree the runtime manages for you. When a CoroutineScope is cancelled, everything it launched is cancelled too, transitively. No orphans.

On Android this maps directly onto lifecycles: viewModelScope dies with the ViewModel, lifecycleScope with the lifecycle owner. Launch inside those and cancellation is automatic. The bugs begin when engineers step outside the structure.

Myth 1: "GlobalScope is fine for fire-and-forget"

GlobalScope.launch { … } creates a coroutine with no parent. Nothing cancels it when the user navigates away, backgrounds the app, or the screen is destroyed. It runs to completion holding whatever it captured — a classic leak, often of a whole ViewModel or Context.

// Leak: unrooted, uncancellable
GlobalScope.launch { repo.sync() }

// Rooted to a lifecycle: cancelled automatically
viewModelScope.launch { repo.sync() }

If you genuinely need work that survives a screen (an upload, say), the answer is still a managed scope — WorkManager, or an application-scoped CoroutineScope you own and can cancel — never GlobalScope.

Myth 2: "Catching Exception is good hygiene"

Cancellation in coroutines works by throwing CancellationException. That means a well-meaning blanket catch is actively dangerous:

// Breaks cancellation: swallows CancellationException
try {
    doWork()
} catch (e: Exception) {
    log(e)
}

When this coroutine is cancelled, doWork() throws CancellationException, your catch swallows it, and the coroutine keeps going as if nothing happened — the exact opposite of what you want. Either catch specific exceptions, or rethrow cancellation explicitly:

try {
    doWork()
} catch (e: CancellationException) {
    throw e            // let cancellation propagate
} catch (e: IOException) {
    log(e)             // handle the ones you actually mean to
}

Myth 3: "One failure shouldn't crash my other coroutines"

Under a normal Job, failure propagates: if one child throws, the parent is cancelled, which cancels the siblings. That's usually correct — if one part of a coordinated operation fails, finishing the rest is often pointless. But when you do want isolation (say, loading three independent dashboard widgets), you opt into it deliberately with a supervisor:

supervisorScope {
    launch { loadProfile() }   // if this fails…
    launch { loadFeed() }      // …these two keep running
    launch { loadNotifications() }
}

supervisorScope (and SupervisorJob) change the propagation rule so a child's failure stays local. The point is that isolation is a choice you make, visible in the code — not an accident of which scope you happened to use.

Where seniors actually spend the effort

Once the lifetimes are right, the interesting work moves to the edges:

  • Cancellation cooperation. Long CPU loops don't cancel unless they check. Call ensureActive() or a suspend point periodically so cancellation is honoured promptly.
  • Exception surface. Decide where errors are handled — a CoroutineExceptionHandler at the scope boundary, or try/catch around specific awaits — and keep it consistent.
  • Flow lifetimes. Collect with repeatOnLifecycle(STARTED) (or flowWithLifecycle) so upstream work stops when the UI isn't visible, instead of burning resources in the background.

The senior takeaway

Coroutines don't make concurrency safe — structured concurrency does. When you keep every coroutine rooted in a lifecycle-bound scope, let cancellation propagate, and opt into isolation explicitly, whole categories of leaks and race conditions simply can't occur. The framework is offering you a guarantee. The mark of a senior engineer is taking it, instead of quietly working around it with a GlobalScope and a catch (e: Exception).

Keep reading