Blog Infos
Structured concurrency in Kotlin relies heavily on CoroutineScope and SupervisorScope to manage coroutine lifecycles. Let’s break down how they work internally and when to use each.

1. CoroutineScope: The Parent-Child Relationship
How It Works:
- Creates a scope where child coroutines are bound to the parent
- If any child coroutine fails, it cancels the parent and all siblings
- Uses a regular
Job()internally
Simple Example:
fun main() = runBlocking {
val scope = CoroutineScope(Job())
scope.launch {
println("Child 1 started") // (1)
delay(100)
throw RuntimeException("Error in Child 1!") // (3)
}
scope.launch {
println("Child 2 started") // (2)
delay(200)
println("Child 2 completed") // (X) Never reaches here!
}
delay(300)
println("Main ends")
}
Output:
Child 1 started Child 2 started Exception in thread "main" RuntimeException: Error in Child 1!
What Happened?
- Child 1 fails → Cancels the entire scope → Child 2 gets cancelled too.
2. SupervisorScope: Isolated Failure Handling
How It Works:
- Creates a scope where child coroutines don’t affect siblings if they fail
- Uses a
SupervisorJob()internally which suppresses exception propagation - You must handle exceptions manually in each child
Simple Example:
fun main() = runBlocking {
val scope = CoroutineScope(SupervisorJob()) // ← Only change!
scope.launch {
println("Child 1 started") // (1)
delay(100)
throw RuntimeException("Error in Child 1!") // (3)
}
scope.launch {
println("Child 2 started") // (2)
delay(200)
println("Child 2 completed") // (4) This now executes!
}
delay(300)
println("Main ends") // (5)
}
Output:
Child 1 started Child 2 started Exception... (logged but doesn't crash) Child 2 completed Main ends
What Changed?
- Child 1 fails → But Child 2 continues execution normally.
Key Differences Table

Job Offers
When to Use Which?
Use CoroutineScope when:
// Transfer money (if either fails, both should roll back)
CoroutineScope(Job()).launch {
val withdraw = async { bank.withdraw(amount) }
val deposit = async { receiver.deposit(amount) }
withdraw.await()
deposit.await() // If this fails, withdraw is cancelled
}
Use SupervisorScope when:
// Fetch user profile + recommendations (failure in one shouldn't cancel the other)
SupervisorScope().launch {
launch { println("Loading profile: ${fetchProfile()}") }
launch { println("Loading ads: ${fetchAds()}") } // If this fails, profile still loads
}
Pro Tip: supervisorScope Builder
For temporary supervisor behavior inside a coroutine:
fun main() = runBlocking {
supervisorScope {
launch {
println("Task 1 running")
throw Exception("Oops!") // (1)
}
launch {
delay(100)
println("Task 2 still runs!") // (2)
}
}
println("Main completes") // (3)
}
Output:
Task 1 running Task 2 still runs! Exception... (logged) Main completes
This creates an isolated “bubble” where failures are contained.
Final Thoughts
- CoroutineScope = Strict parent (kids misbehave → everyone goes home)
- SupervisorScope = Chill parent (one kid messes up → others keep playing)
Choose based on whether failures should be isolated or atomic. For beginners: Start with CoroutineScope and switch to SupervisorScope only when you need independent failure handling.
This article was previously published on proandroiddev.com.



