Structured Concurrency & Cancellation Explained
KOTLIN › Coroutines
**Structured concurrency** is the rulebook that keeps coroutines from leaking, and it's usually the first thing an interviewer wants to see you reason about, because it's the difference between coroutine code you can trust and coroutine code that quietly runs forever. Every coroutine runs inside a scope that bounds its lifetime, and four guarantees follow from that:
1. A parent doesn't finish until all its children finish 2. Cancelling a parent cancels every child 3. An uncaught failure in a child cancels its parent and siblings, unless that failure is isolated 4. No coroutine is ever orphaned, launched work is always someone's child
suspend fun loadScreen() = coroutineScope {
launch { loadHeader() }
launch { loadBody() }
// loadScreen() does not return until BOTH children finish
}
Notice that loadBody() is fire-and-forget, from the caller's point of view it's just a launch with no result to collect, yet loadScreen() still won't return until it's done. That's the whole point: you can call a suspend function and trust that when it returns, every child coroutine it started, tracked or not, has genuinely finished.
**coroutineScope versus supervisorScope** is the first fork interviewers probe once they know you understand the basic guarantees. Both create a scope and wait for all their children, they differ only in what happens when a child fails.
In coroutineScope, one failing child cancels the whole scope, siblings included:
coroutineScope {
launch { throw IOException("A failed") } // cancels the scope
launch { delay(1000); println("B") } // never runs
}
In supervisorScope, a child's failure is isolated, its siblings keep going:
supervisorScope {
launch { throw IOException("A failed") } // isolated
launch { delay(1000); println("B") } // runs normally
}
Reach for supervisorScope when children are genuinely independent, loading three unrelated widgets on a dashboard, where one failing shouldn't take the other two down with it. Reach for coroutineScope when the children are really one operation, like two calls whose results you're about to combine, where a partial result would be meaningless.
Underneath both scopes sits a Job, and that's the actual mechanism, not coroutineScope or supervisorScope themselves. A regular **Job propagates failure in both directions**: a failing child cancels its parent, and a cancelled parent cancels its children. A **SupervisorJob makes that one-directional**, only downward, cancelling the parent still cancels its children, but a child failing does not cancel the parent or its siblings.
val supervisor = SupervisorJob()
val scope = CoroutineScope(supervisor + Dispatchers.Default)
scope.launch { throw RuntimeException("boom") } // isolated, doesn't touch supervisor
scope.launch { delay(1000); println("still alive") } // unaffected
This is what supervisorScope builds on internally. Knowing the Job versus SupervisorJob distinction, rather than just memorizing which scope name to use, is what separates a rote answer from one that shows you actually understand the mechanism.
supervisorScope's isolation is easy to overestimate. It only protects its **direct** children, it is not a blanket rule that nothing inside it can ever cascade. If one of those direct children opens its own regular coroutineScope internally, that inner scope behaves completely normally: a failure inside it still cancels everything inside that inner scope, including any siblings the failing coroutine has *there*.
supervisorScope { // isolates its direct children
launch { // direct child A
coroutineScope { // inner scope uses a normal Job
launch { throw IOException() } // fails the inner scope
launch { println("sibling") } // also cancelled, by the inner scope
}
// child A itself ends up failing
}
launch { println("I survive") } // direct child B, unaffected by A
}
So the isolation boundary is exactly one level: supervisorScope's own direct children are protected from each other, but what happens inside any one of them is governed by whatever scope that child creates. Child A still fails here, supervisorScope just stops that failure from spreading sideways to child B.
Cancellation in coroutines is **cooperative**, calling cancel() only flips a flag. Code has to actually check that flag, or hit a suspension point, to react to it, otherwise it just keeps running:
// BAD: never checks, ignores cancel() entirely
launch {
var i = 0L
while (true) { i++ }
}
Three ways to make a loop cooperative, and they're not interchangeable:
- **isActive**: a boolean you poll yourself, while (isActive) { ... } - **ensureActive()**: throws CancellationException immediately if the job is cancelled, fail-fast, no boolean check needed - **yield()**: also checks cancellation, but additionally suspends so other coroutines on the same dispatcher get a turn to run
launch {
while (isActive) {
heavyComputation()
}
}
A tight CPU loop with no suspension points and no cancellation check is a classic Android leak, it keeps burning cycles long after the screen that started it is gone.
**Never swallow CancellationException.** It's the signal that drives cooperative cancellation up through the structured-concurrency tree, and catching it without rethrowing breaks that silently:
// BAD: catches CancellationException too, coroutine ignores cancel()
launch {
try { delay(1000) }
catch (e: Exception) { log(e) } // swallows it!
}
CancellationException is a subtype of Exception, so a broad catch (e: Exception) catches it right along with everything else. The fix is to catch a specific type instead, so cancellation passes through untouched:
// GOOD
launch {
try { delay(1000) }
catch (e: IOException) { log(e) }
// CancellationException propagates normally
}
This is a genuinely common real-world bug: a broad catch (e: Exception) around a suspending call quietly makes a coroutine uncancellable, and the symptom, work that never stops, shows up far away from the actual cause. As a rule, try/catch around a suspending call should catch the specific failures you actually expect, network errors, parse errors, not Exception in general.
Sometimes cleanup code in a finally block needs to suspend, saving state to disk, say, but by the time finally runs the coroutine is already cancelled, so a normal suspend call there would just throw immediately instead of doing the work.
**withContext(NonCancellable)** is built for exactly this: it lets suspending cleanup run to completion even inside an already-cancelled coroutine.
launch {
try {
delay(1000)
doWork()
} finally {
withContext(NonCancellable) {
db.save(pendingData) // still works, even though we're cancelled
}
}
}
Use it narrowly, just around the cleanup itself, not as a general escape hatch. Wrapping unrelated work in NonCancellable defeats the whole point of cancellation being predictable, and it's easy to accidentally turn a small cleanup step into a chunk of uncancellable work that runs longer than expected.
The opposite problem to NonCancellable is wanting work to stop on its own if it takes too long, without you having to track a Job and call cancel() yourself. withTimeout(millis) { ... } runs a block and, if it hasn't finished within the deadline, cancels it and throws TimeoutCancellationException, which is itself a subtype of CancellationException, so it plays by all the same cooperative-cancellation rules already covered.
// throws TimeoutCancellationException after 2 seconds
withTimeout(2000) {
repo.loadSlowData()
}
If a thrown exception is awkward to handle at that call site, withTimeoutOrNull gives you a null result on timeout instead of throwing:
val result = withTimeoutOrNull(2000) {
repo.loadSlowData()
}
// result is null if loadSlowData() didn't finish in time
Both only work if the code inside actually suspends or checks cancellation somewhere, a withTimeout around a tight, non-suspending loop has exactly the same cooperative-cancellation problem as calling cancel() on one directly.
Cooperative cancellation has a blind spot that isActive cannot help with: **blocking calls inside a coroutine.**
withContext(Dispatchers.IO) {
legacyClient.readBlocking() // a blocking Java call, no suspension points
}
Cancelling this does nothing until readBlocking() returns on its own. There is no suspension point to throw at and no loop to check a flag in, so the coroutine sits in the Cancelling state while a pool thread stays occupied.
If the underlying API responds to thread interruption, which most blocking Java IO does, runInterruptible bridges the two worlds:
withContext(Dispatchers.IO) {
runInterruptible {
legacyClient.readBlocking() // cancellation now calls Thread.interrupt()
}
}
It translates coroutine cancellation into an InterruptedException on the executing thread, which the blocking call surfaces as normal.
When the API does not honour interruption, and many native or third-party clients do not, the only honest answer is to cancel the resource yourself:
suspend fun fetch(): Response = suspendCancellableCoroutine { cont ->
val call = client.newCall(request)
cont.invokeOnCancellation { call.cancel() } // tell the library directly
call.enqueue(/* ... */)
}
The interview point: cancellation is a **protocol**, not a force. Everything in the chain has to participate, and a blocking call that ignores interruption is a link that does not.
Two small cancellation APIs are worth knowing precisely, because both hide a trap.
**cancelAndJoin()** does what its name says, in order: request cancellation, then suspend until the coroutine has actually finished unwinding.
job.cancel() // returns immediately, work may still be unwinding
job.cancelAndJoin() // returns only once the coroutine is genuinely finished
The difference matters whenever the next line depends on the work having stopped: closing a file the coroutine writes to, or replacing a job with a new one. cancel() alone gives you two coroutines briefly running at once, which is a classic source of duplicated network calls when a user retypes in a search box.
private var searchJob: Job? = null
fun search(query: String) {
viewModelScope.launch {
searchJob?.cancelAndJoin() // old one is definitely gone first
searchJob = launch { repo.search(query) }
}
}
**yield()** suspends and immediately reschedules, which does two things at once: it checks for cancellation, throwing if cancelled, and it gives other coroutines on the same dispatcher a turn. In a tight loop on a single-threaded dispatcher, isActive alone lets you monopolise the thread even though you are checking the flag diligently:
while (isActive) { crunch() } // cancellable, but hogs the dispatcher
while (true) { yield(); crunch() } // cancellable AND fair
All of this only matters if a coroutine is actually inside a scope with a real lifecycle. viewModelScope, the scope Android gives every ViewModel, is tied directly to that ViewModel's lifecycle: it's cancelled automatically inside onCleared(), so every coroutine you launch from it is cancelled the moment the user navigates away and the ViewModel is torn down, no manual bookkeeping required.
class MyViewModel : ViewModel() {
fun fetchData() {
viewModelScope.launch { // tied to this ViewModel's lifecycle
_state.value = repo.fetch()
}
}
// onCleared() cancels viewModelScope automatically, no manual cleanup needed
}
GlobalScope is the trap that looks like a shortcut. It has no parent Job and no lifecycle at all, so launching real app work there breaks structured concurrency outright: nothing ever cancels it when the screen that started it goes away, and it isn't tracked by any surrounding scope's failure or completion rules either.
// BAD: not bound to any lifecycle, leaks when the caller is gone
GlobalScope.launch {
repo.fetch()
}
If you find yourself reaching for GlobalScope, that's usually a sign you're missing the right scope to launch from, viewModelScope, lifecycleScope, or one you're passing in explicitly, rather than a sign you need an unscoped one.
**Parallel decomposition** is the payoff of everything so far, and it's the shape most of these rules actually show up in during real work: start two independent pieces of work at once, then combine their results, all while structured concurrency keeps the whole thing safe.
suspend fun loadCombined(): Combined = coroutineScope {
val a = async { repoA.load() }
val b = async { repoB.load() }
Combined(a.await(), b.await()) // both ran concurrently
}
The classic mistake is awaiting too early, which accidentally serializes work that was supposed to run in parallel:
// BAD: sequential, B doesn't even start until A finishes
val a = async { loadA() }.await()
val b = async { loadB() }.await()
Both async calls need to start before either one is awaited. And because they run inside coroutineScope, everything from earlier in this lesson is already protecting the result: if either one fails, the scope cancels the other and rethrows immediately, so the operation fails atomically instead of quietly returning a half-built result. That's the answer worth giving in an interview: structured concurrency isn't a set of API names to recite, it's the guarantee that a suspend function's return means every child it started, successes and failures alike, has already been accounted for.
A common interview question: **how should you pass scopes and dispatchers through your app?** The answer is dependency injection -- never hardcode Dispatchers.IO or viewModelScope deep inside a repository or use case.
The pattern has two parts:
**1. Inject dispatchers, not scopes:**
class UserRepository(
private val api: UserApi,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun getUser(id: String): User = withContext(ioDispatcher) {
api.fetchUser(id)
}
}
The repository does not launch its own coroutines -- it just switches context. The caller (ViewModel) owns the scope and decides when to launch:
class UserViewModel(
private val repo: UserRepository
) : ViewModel() {
fun load(id: String) {
viewModelScope.launch {
val user = repo.getUser(id) // suspends, uses injected IO
}
}
}
**2. In tests, swap the dispatcher:**
@Test
fun `loads user`() = runTest {
val repo = UserRepository(fakeApi, testDispatcher)
val user = repo.getUser("42")
assertEquals("Alice", user.name)
}
This pattern keeps scope ownership at the top (ViewModel/lifecycle), dispatcher choice at the boundary (repository), and testability everywhere. The anti-pattern is a repository that creates its own CoroutineScope(Dispatchers.IO) -- that scope outlives the ViewModel, leaks work, and is impossible to control in tests.