skip to content

What is structured concurrency, and how does the parent Job/scope tie coroutine lifetimes together?

level: middleimportance: must knowfreq 72%

answer

  1. Coroutines form a parent-child Job tree
  2. Parent waits for all children to finish
  3. Cancel parent -> children cancelled (down)
  4. Child failure cancels parent+siblings (up) unless supervisor
  5. coroutineScope structured; GlobalScope unstructured/leaky

basics

~20 s

Structured concurrency means every coroutine runs inside a scope, and a parent waits for all its children to finish. If the parent is cancelled, its children are cancelled too — so nothing is left running or leaked.

solid answer

~40 s

Structured concurrency is the rule that coroutines form a **parent–child tree** anchored in a `CoroutineScope`, and a parent's lifetime contains its children's. When you `launch`/`async` inside a scope, the new coroutine's `Job` becomes a **child** of the scope's `Job`. Consequences: (1) the scope (or an enclosing `coroutineScope { }`) does not complete until **all children complete**; (2) **cancelling the parent cancels all children** (propagation downward); (3) by default, an **uncaught failure in a child cancels its parent and siblings** (propagation upward), unless you use a `SupervisorJob`/`supervisorScope`. This makes leaks structurally impossible — you can't accidentally start a coroutine that outlives its caller, and errors aren't silently swallowed. It is the model's safety mechanism: lifetimes are tied to the `Job` hierarchy rather than left dangling like raw threads.

code

kotlin · 11 lines
kotlin
import kotlinx.coroutines.*

suspend fun process(): Int = coroutineScope {
    val a = async { compute(1) }
    val b = async { compute(2) }
    // If compute(2) throws, 'a' is cancelled too, and process() rethrows.
    // coroutineScope does not return until both children settle -> no leak.
    a.await() + b.await()
}

suspend fun compute(x: Int): Int { delay(50); return x * 10 }

go deeper

for a junior

Knows coroutines live in a scope and cancelling the scope stops them.

for a middle

Explains the parent-child Job tree, that the scope awaits all children, and downward cancellation.

for a senior

Adds upward failure propagation, contrasts coroutineScope vs supervisorScope, and explains why GlobalScope breaks the model.

for a principal

Designs scope hierarchies for lifecycles (request/screen scopes), reasons about supervisor boundaries and cancellation cleanup at scale.

## The core idea **Structured concurrency** says: a coroutine never escapes the scope that started it. Every coroutine has a parent, forming a **tree of `Job`s**, and a parent's lifetime **encloses** all of its children. The opposite — unstructured — is firing off background threads that may outlive their creator, leak, or fail silently. ## How the tree is built Everything runs inside a `CoroutineScope`, which carries a `CoroutineContext` containing a `Job`. When you call a builder: ```kotlin import kotlinx.coroutines.* suspend fun loadAll() = coroutineScope { // creates a child scope val a = async { fetchA() } // child Job of this scope val b = async { fetchB() } // child Job of this scope a.await() + b.await() } // does NOT return until BOTH children finish ``` `async`/`launch` create a new `Job` whose **parent** is the scope's `Job`. This parent–child link is what enforces the guarantees. ## The three guarantees 1. **Wait for children**: a `coroutineScope { }` block (and structured scopes generally) **suspends until every child completes**. The function won't return with work still running in the background. 2. **Cancellation flows down**: cancel the parent `Job` and all descendant coroutines are cancelled. One call tears down the whole subtree. 3. **Failure flows up (by default)**: if a child throws an uncaught exception, it cancels its parent, which cancels the **siblings** too. This surfaces errors instead of losing them. To stop a failing child from killing siblings, use `SupervisorJob` / `supervisorScope`, where children fail independently. ## Why it matters - **No leaks**: you cannot accidentally start a coroutine that runs forever after its caller returned. - **No lost errors**: an exception in a background task isn't swallowed; it propagates to the parent. - **One cancel point**: cancelling a scope (e.g. a `viewModelScope` when a screen closes) cleanly cancels all work it started. ## `coroutineScope` vs `GlobalScope` - `coroutineScope { }` / `withContext` create **structured** child scopes — preferred. - `GlobalScope.launch { }` is **unstructured** — it has no parent, isn't awaited, and isn't cancelled with anything. It's discouraged precisely because it breaks structured concurrency. ## APIs/keywords to name `CoroutineScope`, `coroutineScope { }`, `supervisorScope { }`, `Job`, `SupervisorJob`, `launch`, `async`, `Job.cancel()`, `Job.join()`, `CoroutineContext`, `GlobalScope` (anti-pattern).

  • What is the difference between coroutineScope and supervisorScope?
    In coroutineScope, a child's failure cancels the parent and all siblings. In supervisorScope (a SupervisorJob), children fail independently — one child's failure doesn't cancel its siblings.
  • Why is GlobalScope.launch discouraged?
    It creates an unstructured coroutine with no parent: it isn't awaited, isn't cancelled with any scope, and its failures aren't propagated — so it can leak and hide errors.

Like a manager who can't go home until every team member's task is done — and if one task blows up, the manager calls off the rest rather than letting them run blind.

saying these in an interview costs you the question

  • Thinks a coroutineScope returns before its children finish
  • Unaware that cancelling a scope cancels all child coroutines
  • Reaches for GlobalScope as the normal way to launch work
  • Doesn't know child failure propagates to parent/siblings by default
  • Confuses Job (lifecycle/cancellation) with Dispatcher (thread)

context