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.