Stop Choosing Between coroutineScope and supervisorScope — Fix the Failure Model First
Many developers run into these confusions:
- How should I choose between
coroutineScopeandsupervisorScope? - Where should exceptions be handled?
- Should the Data layer throw exceptions directly, or return a
Result? - Why, after adopting
Result, docoroutineScopeandsupervisorScopeseem to behave identically?
Behind these questions lies an architectural choice:
Should we choose an Exception-driven model, or a Result-driven model?
And coroutineScope and supervisorScope are merely how this question manifests at the coroutine level.
1. First, understand the coroutine exception propagation mechanism
Kotlin coroutine exception propagation fundamentally relies on:
CoroutineContext
↓
Job
↓
Exception propagation relationship
For example:
viewModelScope.launch {
loadUser()
}
If:
suspend fun loadUser() {
throw IOException()
}
Exception propagation:
loadUser()
↓
Current coroutine
↓
viewModelScope Job
↓
CoroutineExceptionHandler
↓
Crash
2. coroutineScope: When a child task fails, the entire task group fails
First, look at:
viewModelScope.launch {
coroutineScope {
launch {
delay(500)
throw RuntimeException("Task A failed")
}
launch {
delay(2000)
println("Task B completed")
}
}
}
Structure:
coroutineScope
|
----------------
| |
Task A Task B
Failed Running
When Task A throws an exception:
Task A failed
↓
coroutineScope detects it
↓
Cancels the entire child coroutine tree
↓
Task B receives CancellationException
This is the core of coroutineScope:
When a child task fails, the entire task group fails.
But note:
If the exception is not caught:
viewModelScope.launch {
coroutineScope {
launch {
throw RuntimeException()
}
}
}
The exception still propagates upward:
Child Coroutine
↓
Parent Job
↓
CoroutineExceptionHandler
↓
Crash !!!
coroutineScope does not automatically handle exceptions for you.
3. supervisorScope: When a child task fails, sibling tasks are not automatically cancelled
Switch to:
viewModelScope.launch {
supervisorScope {
launch {
delay(500)
throw RuntimeException("Task A failed")
}
launch {
delay(2000)
println("Task B completed")
}
}
}
Result:
Task A failed
X
Task B continues executing (you can't see it here, think about why)
Reason:
supervisorScope changes the cancellation propagation relationship between child coroutines.
Normal Job:
Child fails
↓
Cancel siblings
Supervisor:
Child fails
↓
Does not affect siblings
But:
If:
launch {
throw RuntimeException()
}
The exception is not handled:
The exception still enters:
CoroutineExceptionHandler
When unhandled, the Android environment still causes a crash.
4. Problems with the Exception-driven model
At this point, we can see:
Using Exceptions directly to manage business failures leads to two extremes.
Scenario 1: No catching
repository.getUser()
The exception propagates directly:
Exception
↓
Coroutine Job
↓
Crash
Scenario 2: try-catch everywhere
For example:
viewModelScope.launch {
try {
repository.getUser()
} catch(e: Exception){
showError()
}
}
As the business grows:
try-catch
try-catch
try-catch
Eventually:
Business code is drowned in exception handling logic.
If we choose the Exception-driven model, then exceptions must ultimately be caught at some layer.
But in the coroutine world, exceptions don't always travel along the call stack directly into an outer try-catch like normal function calls.
Especially when we use launch to create a new child coroutine, exception propagation is affected by the Job parent-child relationship.
This is also a confusion many developers encounter in practice:
Even though I've clearly written a
try-catchon the outside, why can't I catch the exception from the child coroutine?
To answer this, we need to first understand the new coroutine created by launch and the exception propagation mechanism behind it.
5. Why can't an outer try-catch catch exceptions from launch?
Many people write:
viewModelScope.launch {
try {
launch {
throw RuntimeException()
}
} catch(e: Exception){
println("Catch successful")
}
}
Result:
Cannot catch.
Reason:
launch creates a new coroutine.
Exception propagation:
Child Coroutine
↓
Child Job
↓
Parent Job
↓
CoroutineExceptionHandler
It is not a normal function call:
throw
↓
Function stack
↓
catch
Therefore:
An outer try-catch will not automatically catch exceptions thrown by a child coroutine just because launch created it.
6. When can try-catch take effect?
If the exception occurs in the current coroutine call chain:
viewModelScope.launch {
try {
repository.getUser()
} catch(e: Exception){
println("Catch successful")
}
}
Path:
Current coroutine
↓
suspend function
↓
throw
↓
catch
It can be caught.
But:
If all business logic relies on this approach:
Ultimately:
The upper layers are still filled with try-catch.
7. The Result model: Making business failure data
Earlier, we saw a natural problem with the Exception-driven model:
Once a business failure propagates via an exception, it enters the coroutine's exception propagation system.
This means a failure that is essentially at the business level can further affect:
- The state of the
Job; - Parent-child coroutine relationships;
- Cancellation of sibling coroutines;
- Exception handling boundaries.
Therefore, a question worth deeper consideration is:
Should business failures really be expressed through Exceptions?
My understanding is:
Business failures should be explicitly modeled as much as possible; don't let all business failures rely on exception propagation.
Therefore, we can explicitly model business failures as Result or business states, rather than through exception propagation.
For example, we can convert low-level technical exceptions into business results at an appropriate architectural layer.
For example:
interface UserRepository {
suspend fun getUser(): Result<User>
}
Implementation:
class UserRepositoryImpl(
private val api: UserApi
): UserRepository {
override suspend fun getUser(): Result<User> {
return try {
val user = api.getUser()
Result.success(user)
} catch(e: CancellationException) {
throw e
} catch(e: Exception) {
Result.failure(e)
}
}
}
Upper layer:
viewModelScope.launch {
val result = repository.getUser()
result
.onSuccess {
showUser(it)
}
.onFailure {
showError()
}
}
Business code:
No try-catch needed.
8. The biggest change with Result: The coroutine is unaware of the failure
This is the key to the entire design.
For example:
coroutineScope {
launch {
repository.getUser()
}
launch {
repository.getBanner()
}
}
If:
getUser()
Returns:
Result.failure()
Then:
The coroutine sees:
Task A
↓
Returns Result.failure() normally
↓
No exception thrown
To the coroutine's exception propagation mechanism, Result.failure() is just a normal return value.
It does not trigger Job exception propagation, nor does it automatically cancel sibling coroutines due to a business failure.
Instead of:
Task A failed
↓
throw Exception
Therefore:
It does not trigger:
- Job cancellation;
- Sibling task cancellation;
- Exception propagation.
So:
At this point:
coroutineScope
and
supervisorScope
In business failure scenarios:
Behave completely identically.
9. How Scope responsibilities change under the Result model
| Exception handling method | coroutineScope | supervisorScope |
|---|---|---|
| Uncaught exception throw | Cancels siblings, continues propagating | Does not cancel siblings, continues propagating |
| Caught inside child coroutine | No difference | No difference |
| Returns Result | No difference | No difference |
| CancellationException | Must propagate | Must propagate |
10. CancellationException: It's not a business exception, but a cancellation signal
Mistake:
try {
api.getUser()
}catch(e: Exception){
Result.failure(e)
}
Problem:
Because:
CancellationException
inherits
Exception
So it gets caught.
For example:
User exits the page:
ViewModel destroyed
↓
Coroutine cancelled
↓
CancellationException
↓
Converted to Result.failure
↓
Coroutine continues executing
This leads to:
- Wasted resources;
- Uncontrolled lifecycle.
Correct:
try {
api.getUser()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
Principle:
CancellationException must always propagate upward.
11. Android Architecture Best Practices
UI / ViewModel
|
Consume UiState / Result
|
Coroutine Scope
|
Lifecycle + Concurrency + Cancellation management
|
Repository
|
Catch technical exceptions, convert to business results
|
Retrofit / Room
Data Layer Responsibility
Responsible for:
- IOException
- Network errors
- Data parsing errors
Conversion:
Exception
↓
Result.failure
Do not:
Repository throw IOException
↓
ViewModel try-catch
Coroutine Scope Responsibility
Responsible for:
- Lifecycle
- Concurrency relationships
- Cancellation propagation
- Resource release
Not responsible for:
- Network error display;
- User prompts;
- Business failures.
12. Finally
The core of Kotlin coroutine exception design is not about choosing:
coroutineScope
or
supervisorScope
But first clarifying:
Different types of failures should be handled by different mechanisms.
Business failures belong to the data flow
For example:
- Network failure;
- Server error;
- Data not found.
Should be:
Result
or
Business state model
To pass along.
Coroutine cancellation belongs to the control flow
For example:
- Page destroyed;
- ViewModel cleaned up;
- Timeout cancellation.
Must:
CancellationException
Continue propagating.
Source code: SupervisorScopeVsCoroutineScope