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.
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
CoroutineExceptionHandlerat the scope boundary, ortry/catcharound specific awaits — and keep it consistent. - Flow lifetimes. Collect with
repeatOnLifecycle(STARTED)(orflowWithLifecycle) 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).