Structured Concurrency & Cancellation Explained

KOTLIN › Concurrency

**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.

launch and async handle failure through the exact same mechanism, both create a child Job, and by default an uncaught exception in either one is reported straight up through the job hierarchy to the parent. That's what cancels the parent scope and its other children the moment one fails, and it happens whether the failing coroutine was started with launch or async.

What async does *in addition* is store that exception in its Deferred, so if you call await() you'll also get it rethrown there. That's a second surfacing path, not a replacement for the immediate propagation:

coroutineScope {
    val d = async { repo.load() }   // load() throws
    // no d.await() called anywhere...
    doOtherWork()   // never completes, the scope was already cancelled
}
// exception rethrown here, from the scope itself

The idea that 'the exception just waits for await()' only fully applies to a **root** async, one with no structured parent actively watching it, like GlobalScope.async { ... }. There, nothing happens until something calls await(); if nothing ever does, the exception is silently lost. Inside a real coroutineScope or supervisorScope, the scope is always watching its direct children, so a failure, from launch or async, propagates the instant it happens, await() or not.

So try/catch handles specific, expected failures around a particular suspending call, but what about a genuinely uncaught exception, one nobody wrapped in a try? That's what CoroutineExceptionHandler is for, and its rules are narrower than people expect. It only fires for an uncaught exception from a launch that is a **root** coroutine of its scope, for example installed directly on the scope's context, or on the top-level coroutine inside supervisorScope.

Two things trip people up:

- It's ignored on async. An async coroutine's exception is stored in its Deferred and only surfaces when you call await(), it never reaches the handler, no matter how it's installed. - It's never invoked for CancellationException. That's not a failure, it's normal cancellation working as intended.

val handler = CoroutineExceptionHandler { _, e -> log("caught: $e") }

launch(handler) { throw RuntimeException("oops") }   // handler fires

val d = async(handler) { throw RuntimeException("oops") }
d.await()   // exception rethrown here instead, handler never called

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.

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.

Back to Structured Concurrency & Cancellation