Structured Concurrency
Structured concurrency is the discipline that ties the lifetime of every coroutine you launch to a scope: a scope cannot finish until every coroutine started inside it has finished, an error in one child cancels its siblings and propagates to the parent, and cancelling the parent cancels the whole tree of children. It replaces the old, error-prone pattern of firing off a background thread or callback and hoping someone remembers to wait for it, cancel it, or catch whatever it throws. In Kotlin this isn’t just a convention — it’s built directly into the kotlinx.coroutines library through CoroutineScope, Job, and scope-builder functions like coroutineScope and supervisorScope, so a coroutine structurally cannot outlive the scope that created it.
Overview / How It Works
Every coroutine belongs to a CoroutineScope, and every scope wraps a Job. When you call launch or async inside a scope, the new coroutine gets a child Job attached to the scope’s Job as its parent. This parent-child relationship is what makes concurrency “structured”: the parent Job tracks every child it has spawned and will not reach a completed state until all of its children have completed. The suspending function coroutineScope { ... } takes this further for a block of code — it creates a new child scope, runs the block, and then suspends the calling coroutine (without blocking the underlying thread) until every coroutine launched inside that block has finished. Only then does control return to the code after the coroutineScope call.
Two rules fall out of this tree structure. First, cancellation flows downward: cancelling a parent’s Job cancels every child, grandchild, and so on, recursively. This is why cancelling one network request can reliably stop every sub-task it spawned — you cancel a single Job at the root and the whole subtree unwinds. Second, failure flows upward: if a child throws an unhandled exception, the default coroutineScope behavior cancels the parent’s Job, which in turn cancels every other child (the siblings), and the original exception is rethrown from the coroutineScope call once everything has finished cancelling. Nothing is silently lost — a failure anywhere in the tree either surfaces at a well-defined point or is explicitly caught.
This all works without blocking OS threads. A suspending function like delay or a network call pauses its coroutine and returns the thread it was running on to a shared pool, so thousands of coroutines can be “waiting” at once on a handful of real threads — contrast this with a Thread, which occupies a full OS thread (with its own stack, typically around 1 MB) for as long as it blocks. Structured concurrency’s guarantee — “the scope isn’t done until its children are done” — is therefore cheap to enforce, because waiting on a coroutine just means resuming a suspended function later, not parking a thread.
One practical note for this lesson: the compiler that checks the code on this page only has the Kotlin standard library available, not the external kotlinx-coroutines-core dependency. Every example that calls launch, async, coroutineScope, or delay is shown as an illustrative, non-compiled snippet — in a real project you add the dependency in Gradle (implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:<version>")) and this code compiles and runs exactly as shown. To ground the core idea in code that really does compile here, the last worked example below rebuilds the same “a parent waits for its children” guarantee using nothing but plain JVM threads from the standard library.
Syntax
The table below summarizes the building blocks you use to write structured concurrent code.
| Form | What it does |
|---|---|
coroutineScope { ... } |
Suspends the caller until every coroutine launched inside the block completes; if any child throws, its siblings are cancelled and the exception is rethrown from coroutineScope. |
supervisorScope { ... } |
Like coroutineScope, but a failing child does not cancel its siblings — each child’s exception must be handled where it happens. |
launch { ... } |
Starts a child coroutine that runs for its side effects; returns a Job you can cancel or join, but it produces no value. |
async { ... } |
Starts a child coroutine that computes a value; returns a Deferred<T>, and you call .await() to get the result (and to have any exception rethrown at the call site). |
runBlocking { ... } |
Bridges ordinary blocking code (like fun main() or a test) into the coroutine world by blocking the current thread until its coroutine and all its children finish. Reserve it for entry points, not everyday code. |
In shape, most structured concurrent code follows this pattern: a suspend function opens a scope, launches its child work inside that scope, and returns only after that work has completed.
suspend fun loadDashboard(): Dashboard = coroutineScope {
val user = async { fetchUser() }
val stats = async { fetchStats() }
Dashboard(user.await(), stats.await())
}
Examples
The first example shows the core guarantee: code after a coroutineScope block does not run until every coroutine launched inside that block has completed, regardless of the order in which they finish.
import kotlinx.coroutines.*
fun main() = runBlocking {
println("start")
coroutineScope {
launch {
delay(100)
println("child 1 done")
}
launch {
delay(50)
println("child 2 done")
}
}
println("all children done, scope exited")
}
Output:
start
child 2 done
child 1 done
all children done, scope exited
Even though “child 1” was launched first and requested a longer delay, “child 2” (with the shorter delay) prints first because both coroutines run concurrently. The important part is the last line: coroutineScope does not return control to main until both launched coroutines have finished, exactly like a function call that waits for its work to complete.
Cancellation is the mirror image of this guarantee: cancelling a parent Job recursively cancels every coroutine nested inside it, and each child gets a chance to run its finally block for cleanup.
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
coroutineScope {
launch {
try {
delay(1000)
println("finished")
} finally {
println("child cancelled")
}
}
}
}
delay(100)
job.cancel()
job.join()
println("done")
}
Output:
child cancelled
done
Cancelling the outer job propagates into the coroutineScope block and then into the launch nested inside it. The child never reaches println("finished") — the delay call throws a CancellationException as soon as cancellation reaches it — but its finally block still runs, which is why “child cancelled” prints before “done”.
Exceptions travel the opposite direction: a failure in one child cancels its siblings and is rethrown from the enclosing coroutineScope, where ordinary try/catch can handle it.
import kotlinx.coroutines.*
fun main() = runBlocking {
try {
coroutineScope {
launch {
delay(50)
throw RuntimeException("child A failed")
}
launch {
delay(200)
println("child B finished")
}
}
} catch (e: RuntimeException) {
println("caught: ${e.message}")
}
}
Output:
caught: child A failed
“child B finished” never prints. As soon as child A throws, coroutineScope cancels child B (still inside its 200 ms delay) before rethrowing child A’s exception once both children have settled. The try/catch around the whole block is what stops that exception from crashing main.
The three examples above rely on kotlinx.coroutines, so they’re shown for reading rather than compiled on this page. The same underlying idea — a unit of work isn’t “done” until the sub-tasks it started are done — is something you can see compile and run today using nothing but plain threads from the standard library.
import kotlin.concurrent.thread
fun runStructured() {
println("runStructured: start")
val workers = listOf(
thread { Thread.sleep(50); println("worker 1 done") },
thread { Thread.sleep(20); println("worker 2 done") }
)
workers.forEach { it.join() }
println("runStructured: all workers joined, function returns")
}
fun main() {
runStructured()
println("main: continues after runStructured returns")
}
Output:
runStructured: start
worker 2 done
worker 1 done
runStructured: all workers joined, function returns
main: continues after runStructured returns
runStructured starts two threads and calls .join() on each before returning, so main‘s second println is guaranteed to run only after both workers are finished. Notice “worker 2 done” prints before “worker 1 done”, because it sleeps for less time and finishes on its own thread while runStructured is still blocked inside workers[0].join(). coroutineScope gives you this exact same “don’t return until the children are done” guarantee, but by suspending instead of blocking a thread, so it scales to far more concurrent tasks than threads ever could.
How It Works Step by Step
Walking through the exception-propagation example above:
runBlockingstarts a root coroutine on the calling thread and enters thetryblock.coroutineScopecreates a new child scope tied to that root coroutine’sJoband runs the block, which schedules two child coroutines withlaunch.- Both launched coroutines begin running concurrently; the
coroutineScopecall itself suspends the enclosing coroutine, waiting for both children’sJobs to reach a completed state. - After 50 ms, the first child throws a
RuntimeException. It isn’t caught locally, so it propagates out of that child, marking itsJobas failed. - The scope reacts to the failed child by cancelling the sibling’s
Job— the second child receives aCancellationExceptionthe next time it hits a suspension point, well before its 200 ms delay finishes. - Once every child’s
Jobhas reached a final state (one failed, one cancelled),coroutineScoperethrows the original exception from the failed child. - That exception propagates up to the
try/catcharound thecoroutineScopecall, where it’s caught and printed instead of crashing the program.
Common Mistakes
Breaking structured concurrency almost always means reaching for a scope that isn’t tied to anything, or picking the wrong flavor of scope for how failures should behave.
Mistake 1: Launching into GlobalScope
import kotlinx.coroutines.*
suspend fun refreshUser(userId: String) {
GlobalScope.launch {
val user = fetchUser(userId)
saveToCache(user)
}
}
GlobalScope.launch creates a coroutine whose Job has no parent at all — it isn’t tied to refreshUser‘s caller, so cancelling whatever triggered refreshUser (a cancelled request, a closed screen) does nothing to stop this coroutine. It also isn’t covered by any surrounding try/catch, so an exception inside it can crash the app instead of being handled where you’d expect. It’s a coroutine that has structurally escaped structured concurrency.
import kotlinx.coroutines.*
suspend fun refreshUser(userId: String) = coroutineScope {
launch {
val user = fetchUser(userId)
saveToCache(user)
}
}
coroutineScope ties the launched coroutine’s Job to the Job of whatever called refreshUser, so cancellation and exceptions both flow through it the way you’d expect.
Mistake 2: Reaching for coroutineScope when failures should be independent
import kotlinx.coroutines.*
suspend fun uploadBoth(a: File, b: File) = coroutineScope {
launch { upload(a) }
launch { upload(b) }
}
Because coroutineScope cancels every sibling as soon as one child throws, a failure uploading a also cancels the upload of b — even though the two uploads are logically unrelated and there’s no reason a failing a should stop b from completing.
import kotlinx.coroutines.*
suspend fun uploadBoth(a: File, b: File) = supervisorScope {
launch {
try { upload(a) } catch (e: Exception) { println("upload a failed: ${e.message}") }
}
launch {
try { upload(b) } catch (e: Exception) { println("upload b failed: ${e.message}") }
}
}
supervisorScope keeps a failing child from cancelling its siblings, but the tradeoff is that you’re now responsible for handling each child’s exception yourself — unlike coroutineScope, it won’t surface a child’s failure to a surrounding try/catch for you.
A related trap is calling async and never calling .await() on the resulting Deferred: the coroutine still runs and can still fail, but nothing observes that failure at the call site, so an exception can go unnoticed instead of surfacing where you’d naturally expect it. Always pair every async with an await; if you only care about side effects, use launch instead.
Best Practices
- Never use
GlobalScopein application code; accept or create a properly-scopedCoroutineScopeinstead. - Prefer
coroutineScopewhen children’s failures should abort the whole group, andsupervisorScopewhen children are genuinely independent. - Let suspending calls (like
delayorwithContext) do the waiting instead of blocking calls, so cancellation stays cooperative and responsive. - Use your framework’s structured scope (such as a UI layer’s lifecycle-bound scope) rather than inventing your own long-lived, unscoped one.
- Don’t swallow exceptions silently inside individual
launchblocks; let them propagate through structured concurrency or handle them explicitly. - Reach for
SupervisorJob/supervisorScopedeliberately at the top of a scope you own — it changes cancellation semantics for everything nested under it.
Practice Exercises
- Write a suspend function that uses
coroutineScopeto run threeasynccomputations and sum their results. If the third one throws, what happens to the other two that are still running? - Given a
runBlockingblock with twolaunchchildren that delay for 300 ms and 100 ms respectively before printing, predict the exact print order and explain why. - Rewrite a snippet that calls
GlobalScope.launchinside a suspend function so that it uses structured concurrency instead. What changes about how a caller can cancel that work?
Summary
- Structured concurrency ties every coroutine’s
Jobto a parent, so a scope can’t complete until all its children have. coroutineScopesuspends the caller until its children finish and rethrows a child’s exception after cancelling its siblings.supervisorScopekeeps a failing child from cancelling its siblings, at the cost of having to handle each child’s errors yourself.- Cancellation flows downward through the Job tree; failure flows upward and cancels siblings before being rethrown.
- Suspending functions pause a coroutine without blocking its underlying thread, which is why waiting on many children is cheap.
GlobalScopeand un-awaitedasynccalls are the two most common ways structured concurrency gets accidentally broken.
