PCSalt
YouTube GitHub
Back to Kotlin
Kotlin · 6 min read

Kotlin Coroutines — Part 2: Structured Concurrency

How structured concurrency keeps your coroutines under control — scopes, cancellation, exception handling, and the parent-child relationship that prevents orphan coroutines.


This is Part 2 of the Kotlin Coroutines series.

  1. The Basics
  2. Structured Concurrency (this post)
  3. Real-World Patterns

What is structured concurrency?

In Part 1 we launched coroutines. We used launch, async, and delay. It felt easy — almost too easy. But there’s a system running underneath that keeps everything from falling apart. That system is structured concurrency.

The core idea: every coroutine has a parent, and every parent knows about its children.

Parent Scope
├── Child Coroutine A
├── Child Coroutine B
│   ├── Grandchild Coroutine B1
│   └── Grandchild Coroutine B2
└── Child Coroutine C

This parent-child relationship gives you three guarantees:

  1. A parent waits for all children — the parent coroutine won’t complete until every child finishes.
  2. Cancelling a parent cancels all children — if the parent is cancelled, every child (and grandchild) gets cancelled too.
  3. A child failure propagates to the parent — if a child throws an exception, the parent knows about it. No silent failures.

No orphan coroutines. No fire-and-forget that silently leaks. No exceptions swallowed into the void. That’s structured concurrency.

Think of it like a team lead at work. The lead assigns tasks to team members. The lead doesn’t go home until everyone’s done. If the project gets cancelled, everyone stops. If someone on the team hits a critical problem, the lead finds out — they don’t discover it three weeks later in a production incident.

CoroutineScope — the container for your coroutines

Every coroutine runs inside a scope. The scope is what establishes the parent-child hierarchy. You can’t launch a coroutine without one.

coroutineScope {}

The most common scope builder. It creates a new scope, runs the block inside it, and waits for all children to finish before returning.

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun doWork() = coroutineScope {
  launch {
    delay(1000)
    println("Task A done")
  }
  launch {
    delay(500)
    println("Task B done")
  }
  println("Both tasks launched")
}

suspend fun main() {
  doWork()
  println("doWork() completed — both children finished")
}

Output:

Both tasks launched
Task B done
Task A done
doWork() completed — both children finished

doWork() doesn’t return until both launched coroutines finish. That’s the scope holding things together.

supervisorScope {}

Similar to coroutineScope, but with one key difference — a child failure does not cancel sibling coroutines. We’ll dig into this later, but here’s the preview:

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope

suspend fun doWorkSupervised() = supervisorScope {
  launch {
    delay(500)
    throw RuntimeException("Task A failed")
  }
  launch {
    delay(1000)
    println("Task B completed")
  }
}

With coroutineScope, Task B would get cancelled when Task A fails. With supervisorScope, Task B keeps running.

Why scope matters

Without a scope, you’d use GlobalScope.launch {}. Don’t do that. GlobalScope is the coroutine equivalent of a global variable — it lives forever, has no parent, and nobody cleans it up.

// Don't do this
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch

fun leakyFunction() {
  GlobalScope.launch {
    // This coroutine lives until the process dies
    // or until it finishes on its own.
    // Nobody is tracking it. Nobody will cancel it.
  }
}

Structured concurrency says: give every coroutine a scope with a well-defined lifetime. An Android Activity, a ViewModel lifecycle, a request handler — these are natural scopes.

Cancellation — how it propagates

Cancellation is cooperative in Kotlin coroutines. A coroutine doesn’t get forcefully killed — it gets asked to stop, and it should check and comply.

How cancellation flows

When a scope or job is cancelled, cancellation flows downward through the hierarchy:

Scope (cancelled)
├── Child A (cancelled)
├── Child B (cancelled)
│   ├── Grandchild B1 (cancelled)
│   └── Grandchild B2 (cancelled)
└── Child C (cancelled)

Every child and grandchild receives a CancellationException. Suspension points like delay(), yield(), and withContext() automatically check for cancellation and throw this exception.

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main() = coroutineScope {
  val job = launch {
    repeat(1000) { i ->
      println("Working on iteration $i")
      delay(200) // <-- checks for cancellation here
    }
  }

  delay(550)
  println("Cancelling the job")
  job.cancel()
  job.join() // wait for cancellation to complete
  println("Job cancelled")
}

Output:

Working on iteration 0
Working on iteration 1
Working on iteration 2
Cancelling the job
Job cancelled

The coroutine ran three iterations. On the fourth, delay() detected that the job was cancelled and threw CancellationException, ending the coroutine.

Cooperative cancellation — isActive and ensureActive()

What if your coroutine is doing CPU-heavy work with no suspension points? Cancellation won’t happen automatically because there’s no delay() or yield() to check.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

suspend fun main() = coroutineScope {
  val job = launch(Dispatchers.Default) {
    var sum = 0L
    // Check isActive to cooperate with cancellation
    while (isActive) {
      sum++
      if (sum % 1_000_000 == 0L) {
        println("Sum reached $sum")
      }
    }
    println("Loop ended, sum = $sum")
  }

  kotlinx.coroutines.delay(50)
  println("Cancelling")
  job.cancelAndJoin()
  println("Done")
}

isActive returns false when the coroutine is cancelled. You check it manually.

Alternatively, use ensureActive() — it throws CancellationException if the coroutine is no longer active:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

suspend fun main() = coroutineScope {
  val job = launch(Dispatchers.Default) {
    var sum = 0L
    while (true) {
      ensureActive() // throws CancellationException if cancelled
      sum++
    }
  }

  kotlinx.coroutines.delay(50)
  job.cancelAndJoin()
  println("Cancelled")
}

The difference: isActive lets you exit gracefully. ensureActive() throws immediately.

CancellationException is special

CancellationException is not treated as a failure. It’s the normal way a coroutine says “I was cancelled.” The parent doesn’t consider a cancelled child as a failure — it doesn’t trigger error propagation.

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main() = coroutineScope {
  val job = launch {
    try {
      delay(5000)
    } catch (e: kotlinx.coroutines.CancellationException) {
      println("Caught cancellation: ${e.message}")
      throw e // Always re-throw CancellationException!
    }
  }

  delay(100)
  job.cancel()
  job.join()
  println("Parent is fine — cancellation is not a failure")
}

Output:

Caught cancellation: StandaloneCoroutine was cancelled
Parent is fine — cancellation is not a failure

Always re-throw CancellationException if you catch it. Swallowing it breaks the cancellation mechanism and the coroutine will continue running even though it was asked to stop.

Cancellation in practice

Cancelling a scope cancels all children

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main() {
  val scope = CoroutineScope(Dispatchers.Default + Job())

  scope.launch {
    delay(1000)
    println("Task 1 done") // won't print
  }
  scope.launch {
    delay(1000)
    println("Task 2 done") // won't print
  }
  scope.launch {
    delay(1000)
    println("Task 3 done") // won't print
  }

  delay(200)
  println("Cancelling the entire scope")
  scope.cancel()
  delay(1500)
  println("None of the tasks completed")
}

Output:

Cancelling the entire scope
None of the tasks completed

One call to scope.cancel() and every child is gone.

Timeouts with withTimeout and withTimeoutOrNull

Sometimes you want a coroutine to stop if it takes too long.

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull

suspend fun main() = coroutineScope {
  // withTimeout throws TimeoutCancellationException
  try {
    withTimeout(500) {
      delay(1000)
      println("This won't print")
    }
  } catch (e: kotlinx.coroutines.TimeoutCancellationException) {
    println("Timed out: ${e.message}")
  }

  // withTimeoutOrNull returns null instead of throwing
  val result = withTimeoutOrNull(500) {
    delay(1000)
    "completed"
  }
  println("Result: $result")
}

Output:

Timed out: Timed out waiting for 500 ms
Result: null

withTimeoutOrNull is usually the cleaner choice — no try-catch needed, and the null result clearly communicates “it didn’t finish in time.”

Exception handling

When a child coroutine throws an exception (not CancellationException), what happens?

Default behavior in coroutineScope

  1. The failing child is cancelled.
  2. All sibling coroutines are cancelled.
  3. The exception propagates to the parent.
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main() {
  try {
    coroutineScope {
      launch {
        delay(500)
        throw IllegalStateException("Task A failed!")
      }
      launch {
        delay(1000)
        println("Task B done") // won't print — cancelled by sibling failure
      }
      launch {
        delay(1000)
        println("Task C done") // won't print either
      }
    }
  } catch (e: IllegalStateException) {
    println("Caught: ${e.message}")
  }
}

Output:

Caught: Task A failed!

Task B and Task C were cancelled because Task A failed. The exception was then thrown from coroutineScope, where we caught it.

CoroutineExceptionHandler

For launch-based coroutines at the top level of a scope (not inside coroutineScope {}), you can install a CoroutineExceptionHandler:

import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main() {
  val handler = CoroutineExceptionHandler { _, exception ->
    println("Caught in handler: ${exception.message}")
  }

  val scope = CoroutineScope(SupervisorJob() + handler)

  scope.launch {
    throw RuntimeException("Something went wrong")
  }

  scope.launch {
    delay(500)
    println("This sibling survives")
  }

  delay(1000)
}

Output:

Caught in handler: Something went wrong
This sibling survives

Important: CoroutineExceptionHandler only works with launch (not async) and only at the root level. It’s a last resort handler, not a replacement for proper error handling with try-catch.

try-catch in coroutines

For async, wrap the await() call in try-catch:

import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.supervisorScope

suspend fun main() = supervisorScope {
  val deferred = async {
    throw ArithmeticException("Division by zero")
  }

  try {
    val result = deferred.await()
  } catch (e: ArithmeticException) {
    println("Caught from async: ${e.message}")
  }
}

Output:

Caught from async: Division by zero

For launch, wrap the body of the coroutine itself in try-catch:

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

suspend fun main() = coroutineScope {
  launch {
    try {
      throw RuntimeException("Oops")
    } catch (e: RuntimeException) {
      println("Caught inside launch: ${e.message}")
    }
  }
}

supervisorScope vs coroutineScope

This is the key decision you’ll make when designing concurrent code. The question is simple: when one child fails, should siblings be cancelled?

coroutineScope — all or nothing

One failure cancels everyone. Use this when your children are working on the same logical task and a partial result is useless.

import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay

suspend fun fetchUserProfile() = coroutineScope {
  val user = async { fetchUser() }           // required
  val preferences = async { fetchPrefs() }   // required
  val avatar = async { fetchAvatar() }       // required

  // If any one of these fails, the profile is incomplete
  // — cancel everything and report the error
  UserProfile(user.await(), preferences.await(), avatar.await())
}

// Simulated functions
data class UserProfile(val user: String, val prefs: String, val avatar: String)
suspend fun fetchUser(): String { delay(100); return "User" }
suspend fun fetchPrefs(): String { delay(100); return "Prefs" }
suspend fun fetchAvatar(): String { delay(100); return "Avatar" }

supervisorScope — independent children

One failure doesn’t affect others. Use this when children are doing independent tasks and partial success is acceptable.

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope

suspend fun sendNotifications() = supervisorScope {
  launch {
    sendEmail()    // if email fails...
  }
  launch {
    sendSms()      // ...SMS should still be sent
  }
  launch {
    sendPush()     // ...push should still be sent
  }
}

suspend fun sendEmail() { delay(100); println("Email sent") }
suspend fun sendSms() { delay(100); println("SMS sent") }
suspend fun sendPush() { delay(100); println("Push sent") }

Here’s the mental model:

coroutineScope:        supervisorScope:
One fails, all fail    One fails, others continue

  Parent                  Parent (supervisor)
  ├── A (fails)           ├── A (fails)
  ├── B (cancelled!)      ├── B (still running)
  └── C (cancelled!)      └── C (still running)

Job hierarchy

Behind every coroutine is a Job. The Job is the handle you use to control the coroutine’s lifecycle — cancel it, wait for it, check its state.

Job and SupervisorJob

A Job cancels all children when one child fails. A SupervisorJob lets children fail independently.

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main() {
  // Regular Job: child failure cancels siblings
  val regularScope = CoroutineScope(Job())

  // SupervisorJob: children are independent
  val supervisorScope = CoroutineScope(SupervisorJob())
}

Parent-child relationships

When you launch a coroutine inside a scope, it automatically becomes a child of that scope’s job:

import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

suspend fun main() = coroutineScope {
  val parentJob = coroutineContext[Job]

  val childJob = launch {
    println("My parent: ${coroutineContext[Job]?.parent}")
    println("Am I a child? ${coroutineContext[Job]?.parent == parentJob}")
  }

  childJob.join()
}

Output:

My parent: ScopeCoroutine{Active}@...
Am I a child? true

job.cancel() and job.join()

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main() = coroutineScope {
  val job = launch {
    repeat(10) { i ->
      println("Working $i")
      delay(200)
    }
  }

  delay(550)
  println("Requesting cancellation")
  job.cancel()   // request cancellation
  job.join()     // wait until cancellation completes
  println("Job is done: ${job.isCompleted}")
  println("Job was cancelled: ${job.isCancelled}")
}

Output:

Working 0
Working 1
Working 2
Requesting cancellation
Job is done: true
Job was cancelled: true

You can also use job.cancelAndJoin() as a shorthand for cancel() followed by join().

Job states

A Job goes through these states:

                          ┌──────────┐
         start()          │          │  cancel()
New ────────────▶ Active ─┤          ├──────────▶ Cancelling ──▶ Cancelled
                          │          │
                          │ complete │
                          └────┬─────┘


                          Completing ──▶ Completed
  • New — created with lazy start, not yet started.
  • Active — running.
  • Completing — finished its own work, waiting for children.
  • Completed — done.
  • Cancelling — cancellation requested, running cleanup (finally blocks).
  • Cancelled — done after cancellation.

Real example — parallel API calls with failure

Let’s put everything together. We have three API calls to make in parallel. One of them fails. Let’s see how coroutineScope and supervisorScope handle it differently.

With coroutineScope — all cancelled on failure

import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay

suspend fun fetchUserData(): String {
  println("[User] Starting fetch...")
  delay(300)
  println("[User] Done")
  return "User: Alice"
}

suspend fun fetchOrders(): String {
  println("[Orders] Starting fetch...")
  delay(200)
  println("[Orders] Failing!")
  throw RuntimeException("Orders service unavailable")
}

suspend fun fetchRecommendations(): String {
  println("[Recs] Starting fetch...")
  delay(500)
  println("[Recs] Done")
  return "Recs: Kotlin Books"
}

suspend fun loadDashboardStrict() = coroutineScope {
  val user = async { fetchUserData() }
  val orders = async { fetchOrders() }
  val recs = async { fetchRecommendations() }

  println("User: ${user.await()}")
  println("Orders: ${orders.await()}")
  println("Recs: ${recs.await()}")
}

suspend fun main() {
  try {
    loadDashboardStrict()
  } catch (e: RuntimeException) {
    println("Dashboard failed: ${e.message}")
  }
}

Output:

[User] Starting fetch...
[Orders] Starting fetch...
[Recs] Starting fetch...
[Orders] Failing!
Dashboard failed: Orders service unavailable

User and Recs were cancelled as soon as Orders failed. Neither printed “Done”. The entire dashboard load failed because one piece was missing.

With supervisorScope — others continue

import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.supervisorScope

suspend fun loadDashboardResilient() = supervisorScope {
  val user = async { fetchUserData() }
  val orders = async { fetchOrders() }
  val recs = async { fetchRecommendations() }

  val userData = try {
    user.await()
  } catch (e: RuntimeException) {
    "User: unavailable"
  }

  val orderData = try {
    orders.await()
  } catch (e: RuntimeException) {
    "Orders: unavailable"
  }

  val recData = try {
    recs.await()
  } catch (e: RuntimeException) {
    "Recs: unavailable"
  }

  println(userData)
  println(orderData)
  println(recData)
}

suspend fun main() {
  loadDashboardResilient()
}

Output:

[User] Starting fetch...
[Orders] Starting fetch...
[Recs] Starting fetch...
[Orders] Failing!
[User] Done
[Recs] Done
User: Alice
Orders: unavailable
Recs: Kotlin Books

Orders failed, but User and Recs completed successfully. The dashboard shows what it can and gracefully degrades for the rest.

This is the power of choosing the right scope. coroutineScope for “all or nothing.” supervisorScope for “best effort.”

Quick reference

BehaviorcoroutineScopesupervisorScope
Child failure cancels siblings?YesNo
Parent waits for all children?YesYes
Exception propagationTo parent immediatelyTo parent only if uncaught
Use whenTasks are dependentTasks are independent

What’s next

In Part 3, we’ll take everything from Parts 1 and 2 and apply it to real-world patterns — Flow for reactive streams, retries with exponential backoff, parallel decomposition, coroutines in Android with viewModelScope, and testing coroutines. The theory is done. Time to build real things.