skip to content

Structured Concurrency Principle

Structured concurrency means work is always launched into a scope that waits for it, so there is no such thing as a coroutine nobody is responsible for. Contrast it with launching a bare thread or future, and the benefit becomes obvious.

part ofKotlinoverview, primer and where to startread it →
on this pageshow

questions

5

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

open as a page

Contrast launching work with coroutineScope { } versus GlobalScope.launch { }. Which respects structured concurrency, and what goes wrong with the other?

level: middleimportance: must knowfreq 70%

basics

~10 s

coroutineScope ties work to the current call and waits for it to finish. GlobalScope ties work to the whole app, so nobody waits for it or cancels it, and it can leak.

open as a page

Explain precisely when a coroutineScope { } block returns. If the block's last statement runs but a launched child is still working, what happens?

level: middleimportance: should knowfreq 55%

basics

~10 s

The block returns only after every coroutine started inside it has finished. Even if the last line ran, if a child is still working the block keeps waiting for it.

open as a page

How does the structured concurrency principle shape the design of suspend functions that do internal concurrency? Why shouldn't such a function expose its own background coroutines?

level: seniorimportance: should knowfreq 45%

basics

~10 s

A suspend function should finish all the concurrency it starts before it returns, using coroutineScope. That way callers never inherit hidden background work they didn't ask for.

open as a page

Before structured concurrency, ad-hoc background tasks (raw threads, GlobalScope, detached callbacks) were the norm. What concrete classes of bugs does the structured concurrency principle eliminate, and what is the cost or constraint it imposes?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

It removes leaked and orphaned tasks, lost errors, and forgotten cancellation, by making every task have an owner that waits for it. The cost is that you must always have a scope and your code suspends until children finish.

open as a page