跪拜 Guibai
← Back to the summary

Stop Choosing Between coroutineScope and supervisorScope — Fix the Failure Model First

Many developers run into these confusions:

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-catch on 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:

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:

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:

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:

Conversion:

Exception

↓

Result.failure

Do not:

Repository throw IOException

↓

ViewModel try-catch

Coroutine Scope Responsibility

Responsible for:

Not responsible for:


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:

Should be:

Result

or

Business state model

To pass along.


Coroutine cancellation belongs to the control flow

For example:

Must:

CancellationException

Continue propagating.

Source code: SupervisorScopeVsCoroutineScope