Choosing Between coroutineScope, supervisorScope, and GlobalScope in Kotlin
A decision guide for Kotlin structured concurrency: when to use coroutineScope for fail-fast aggregation, supervisorScope for independent tasks, and why GlobalScope leaks work.
24 Apr 2026, 18:06 UTC

When you launch concurrent child coroutines in Kotlin, the scope builder you pick determines whether one failure cancels every sibling or only itself. The useful takeaway: use coroutineScope for all-or-nothing aggregations, supervisorScope when independent children must survive each other's failures, and avoid GlobalScope except for work that genuinely lives as long as the process.
Constraints that drive the choice
Three questions narrow the decision:
- Is partial failure acceptable? If a profile screen needs both user data and permissions, a missing permissions payload makes the whole screen invalid.
- Do children share a lifetime? If one task is cancelled, should the others stop immediately?
- What owns the work? A UI screen, a background service, or the process itself?
Comparing the supported options
| Builder | Failure propagation | Caller suspension | Best for | Primary risk |
|---|---|---|---|---|
coroutineScope | Cancels all children on first uncaught exception | Suspends until all children finish | Aggregating results where every task must succeed | Couples task lifetimes; one failure ruins the batch |
supervisorScope | Isolates failures; siblings continue unless explicitly cancelled | Suspends until all children finish | Independent dashboard widgets or multi-section loading | Requires explicit per-child error handling |
GlobalScope | No structured cancellation; uncaught exceptions reach the thread's handler | Does not suspend the caller | Process-lifetime daemons only | Leaked work, difficult testing, no automatic cancellation |
Trade-offs in failure handling and lifetime
coroutineScope simplifies error handling because the builder rethrows the first child exception and cancels the rest automatically. That convenience comes at the cost of coupling: if you fetch three independent analytics events and one endpoint times out, the other two are cancelled even though their results were still useful.
supervisorScope inverts that trade-off. A failed child is contained, but you must handle the exception yourself—either with try/catch around each await() or by attaching a CoroutineExceptionHandler. A common trap: an async block inside supervisorScope that throws before you await it can silently store the exception in the Deferred. Always await the result or install a handler.
GlobalScope.launch creates an unstructured top-level coroutine tied only to the process. It maximizes scheduling freedom because no calling scope can cancel it, but that same freedom causes leaked work when a UI component is destroyed and makes tests non-deterministic. On Android, prefer lifecycleScope or viewModelScope from AndroidX rather than GlobalScope.
Validating the behavior with runTest
You can verify the failure-propagation difference in a unit test using kotlinx-coroutines-test. In your module's build.gradle.kts, add the test dependency and pin the version:
dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.x.y")
}
Replace 1.x.y with a specific released version. Core scope semantics are stable in kotlinx.coroutines 1.6+, but experimental builders may change; review release notes when upgrading.
The following pattern checks that supervisorScope lets a healthy sibling finish while a failing child throws. It is a diagnostic pattern to run in your environment, not a claim of observed output:
@Test
fun `supervisorScope isolates sibling failure`() = runTest {
supervisorScope {
val failing = async<Int> {
delay(10)
throw IOException("simulated failure")
}
val healthy = async<Int> {
delay(50)
42
}
// The healthy child completes despite the failing sibling
assertEquals(42, healthy.await())
// The failing child still throws when awaited
assertFailsWith<IOException> { failing.await() }
}
}
Repeat the same structure with coroutineScope to confirm the opposite behavior: the first exception cancels the sibling before it can finish.
@Test
fun `coroutineScope cancels siblings on first failure`() = runTest {
var siblingCancelled = false
assertFailsWith<IOException> {
coroutineScope {
val failing = async<Int> {
delay(10)
throw IOException("simulated failure")
}
val healthy = async<Int> {
try {
delay(100)
42
} catch (e: CancellationException) {
siblingCancelled = true
throw e
}
}
failing.await()
healthy.await()
}
}
assertTrue(siblingCancelled)
}
Run these tests from your IDE or with ./gradlew test in the module directory. Expected checks: the first test passes because healthy.await() returns before the failure propagates; the second passes because the catch block records cancellation. If the supervisorScope test fails with an uncaught exception, you likely forgot to await the failing Deferred or attach a handler.
Limitations and practical verification
These builders control child coroutines, not the parent. Wrapping supervisorScope around code that already runs inside a failing parent job does not make the parent resilient; the parent still controls overall cancellation. Catch exceptions at the appropriate level.
Android scopes such as viewModelScope come from AndroidX lifecycle libraries, not kotlinx.coroutines. Verify that your androidx.lifecycle:lifecycle-runtime-ktx version matches your compileSdk and minSdk, because cancellation timing depends on the AndroidX release.
To inspect the hierarchy at runtime, log the Job from coroutineContext inside each child and confirm the parent-child relationship. A quick check in a debug build:
val job = coroutineContext[Job]
println("Parent: ${job?.parent}, isActive: ${job?.isActive}")
If a child reports an inactive parent after a sibling failure, the cancellation semantics are working as expected. This snippet is illustrative; run it in your own debugger or logging setup to confirm the hierarchy for your specific Kotlin and kotlinx.coroutines versions.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.