skip to content

What is structured concurrency in Kotlin coroutines, and what core guarantee does it give you?

level: juniorimportance: must knowfreq 80%

answer

  1. Every coroutine has a parent
  2. Parent waits for all children
  3. Cancellation flows down, failure flows up
  4. coroutineScope { } suspends until children done
  5. GlobalScope = unstructured = leak

basics

~10 s

Every coroutine runs inside a scope, and that scope won't finish until all the coroutines it started have finished. This stops background work from being forgotten or leaking.

solid answer

~40 s

Structured concurrency means every coroutine is launched inside a CoroutineScope and becomes a child of that scope's Job. A scope (or a suspending coroutineScope { } block) cannot complete until all of its children complete. This binds the lifetime of background work to a well-defined parent, so coroutines can't be silently orphaned or leaked. It also gives automatic propagation: cancelling the scope cancels every child, and (in non-supervised scopes) a child failure cancels its siblings and the parent. You launch via builders like launch and async on a scope, and you create temporary scopes with coroutineScope { }. The opposite — calling GlobalScope.launch — breaks the principle because the work is tied to the whole process, not to a caller, so nobody waits for it or cancels it.

code

kotlin · 8 lines
kotlin
import kotlinx.coroutines.*

suspend fun work() = coroutineScope {
    launch { delay(100); println("child A done") }
    launch { delay(50); println("child B done") }
    println("block body finished, but coroutineScope still waits")
}
// coroutineScope returns ONLY after both children print

go deeper

for a junior

Can state the rule: coroutines live in a scope, the scope waits for its children, and this prevents leaks.

for a middle

Adds that launch/async are scope extensions, that coroutineScope suspends until children finish, and that cancellation/failure propagate.

for a senior

Frames it as lifetime ownership, contrasts with GlobalScope, and explains the parent 'completing' state and downward cancel / upward failure propagation.

for a principal

Discusses how the guarantee shapes API design (suspend functions own their concurrency), error/cancellation semantics, and how it composes across module boundaries.

## The problem it solves With raw threads or callbacks, you can start background work and lose track of it: the caller returns, but the work keeps running with no owner. Nobody waits for it, nobody cancels it, and errors vanish. These are **leaked** or **orphaned** tasks. **Structured concurrency** is the rule that fixes this: *every coroutine has a parent, and a parent cannot complete until all its children have completed.* ## The building blocks - **Coroutine**: a suspendable unit of work, cheaper than a thread. - **CoroutineScope**: an object that owns coroutines. It carries a `CoroutineContext`, which includes a **Job** (the handle representing the lifecycle). - **Coroutine builders**: `launch { }` (fire-and-forget, returns a `Job`) and `async { }` (returns a `Deferred<T>` you `await()`). They are *extension functions on `CoroutineScope`* — you can only start a coroutine if you have a scope. - **`coroutineScope { }`**: a *suspending* function that creates a child scope, runs the block, and **suspends until every coroutine started inside it finishes** before returning. ## The guarantee When you `launch` inside a scope, the new coroutine's `Job` becomes a **child** of the scope's `Job`. The parent stays in a "completing" state until all children are done. Three consequences follow: 1. **No leaks** — work is bound to a caller's lifetime; when the scope ends, so does the work. 2. **Cancellation propagates down** — cancelling the parent cancels all children. 3. **Failures propagate up** — in an ordinary (non-supervisor) scope, an uncaught child failure cancels siblings and the parent. ```kotlin import kotlinx.coroutines.* suspend fun loadDashboard(): Pair<User, List<Order>> = coroutineScope { val user = async { fetchUser() } // child 1 val orders = async { fetchOrders() } // child 2 // coroutineScope does NOT return until BOTH children finish user.await() to orders.await() } ``` If `fetchOrders()` throws, `coroutineScope` cancels `fetchUser()` too and rethrows — no half-finished work escapes. ## Breaking the principle `GlobalScope.launch { }` ties the coroutine to the whole application lifetime, not to a caller. Nobody awaits or cancels it, so it can leak. That is exactly the unstructured pattern structured concurrency exists to prevent; `GlobalScope` is marked `@DelicateApi`. ## Why it matters It makes concurrency *local and predictable*: you can read a function and know that when it returns, all the work it spawned is finished or cancelled — no dangling background tasks.

  • Why is GlobalScope.launch discouraged?
    It ties the coroutine to the application lifetime, not to a caller, so no one waits for or cancels it — that's an orphaned/leaked coroutine, the exact opposite of structured concurrency.
  • Name one builder that requires a CoroutineScope.
    launch and async are both extension functions on CoroutineScope, so you need a scope to call them.

Like a function that doesn't return until every helper thread it spawned has joined — the caller never leaves work running behind it.

saying these in an interview costs you the question

  • Thinking coroutineScope { } returns as soon as the last line of the block runs, ignoring still-running children
  • Believing GlobalScope is the normal way to start coroutines
  • Confusing 'coroutine' with 'thread' (a coroutine is not a thread)
  • Saying scopes are only about threading, not lifecycle/ownership
  • Not knowing that launch returns a Job and async returns a Deferred

context