--- title: "Kotlin Coroutines: Job Hierarchies, Cancellation Propagation, and the SupervisorJob Contract" published: true description: "Master Kotlin coroutines Job hierarchies, cancellation propagation, and SupervisorJob to eliminate silent failures in production Android and backend systems." tags: kotlin, android, architecture, mobile canonical_url: https://mvpfactory.co/blog/kotlin-coroutines-structured-concurrency --- What We Will Build
By the end of this workshop you will understand exactly why coroutines silently swallow errors in Hilt-injected Android apps — and you will have a working pattern to prevent it. We will walk through Job parent-child contracts, how CancellationException behaves differently from every other exception, where SupervisorJob belongs (and where it does not), and the Flow<Result<T>> boundary pattern I use in every production ViewModel.
The most dangerous coroutines bugs are not crashes. They are silent failures. A network call throws, the coroutine disappears, the UI never updates, and your error tracker shows nothing.
Prerequisites
- Kotlin 1.7+
-
kotlinx-coroutines-coreandkotlinx-coroutines-android - Basic familiarity with
launch,async, andCoroutineScope - Hilt for the ViewModel wiring section (optional but relevant)
Step 1 — Understand the Job tree
Every coroutine you launch inherits a Job that is a child of its scope's Job. This forms a tree at runtime. Exception propagation depends entirely on what kind of Job sits at each node.
Here is the contract in full:
| Exception Type | Direction | Default Job | SupervisorJob |
|---|---|---|---|
CancellationException | Child → Parent | Parent not cancelled | Parent not cancelled |
Any other Exception | Child → Parent | Parent is cancelled | Parent not cancelled |
| Parent cancelled | Parent → Children | All children cancelled | All children cancelled |
CancellationException is the cooperative exit signal — it travels up but does not tear down the parent. Every other uncaught exception does. This is intentional: withTimeout should cancel a single operation without nuking your entire viewModelScope.
Step 2 — See the cascade in practice
// Standard Job — one child fails, all siblings cancelled val scope = CoroutineScope(Job() + Dispatchers.IO) scope.launch { fetchUser() } // throws IOException scope.launch { fetchSettings() } // gets cancelled silently // SupervisorJob — failure is isolated val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) scope.launch { fetchUser() } // throws IOException — handled locally scope.launch { fetchSettings() } // continues unaffected SupervisorJob extends isolation to non-cancellation failures. A child crash stays local. This is the correct default for a ViewModel, where one failing fetch should not cancel an unrelated polling operation.
Step 3 — Fix the Hilt ViewModel wiring that swallows errors
viewModelScope uses SupervisorJob internally — correct. But the wiring breaks when you inject a repository that launches its own coroutines into an application-scoped CoroutineScope.
@Singleton class UserRepository @Inject constructor( @ApplicationScope private val appScope: CoroutineScope ) { fun syncUser() = appScope.launch { // Exception here propagates into appScope's SupervisorJob // It never reaches viewModelScope's exception handler api.sync() } } The appScope is SupervisorJob-backed. The exception lands there, hits the scope's CoroutineExceptionHandler (if any exists), and dies. Your ViewModel launched syncUser(), received a Job reference, and has zero visibility into what happened downstream.
Here is the pattern that surfaces the failure at the boundary instead:
fun syncUser(): Flow<Result<Unit>> = flow { emit(Result.success(api.sync())) }.catch { e -> emit(Result.failure(e)) }.flowOn(Dispatchers.IO) Now the ViewModel owns the error-handling contract. The Flow is cold, the scope boundary is explicit, and failures cannot disappear into a supervisor hierarchy you do not control.
Step 4 — Always attach a handler to long-lived scopes
Here is the gotcha that will save you hours in production: a SupervisorJob scope without a CoroutineExceptionHandler logs nothing by default in release builds.
val handler = CoroutineExceptionHandler { _, throwable -> Timber.e(throwable, "Unhandled coroutine exception") analytics.recordError(throwable) } val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler) Non-negotiable in production.
Gotchas
scope.isActive is false after the call returns. That means the scope itself was cancelled — usually a scope leak or lifecycle mismatch, not an exception in your coroutine.
Calling await() on a Deferred from a different scope. It does not inherit your cancellation context. This breaks the structured concurrency guarantee silently.
Using supervisorScope {} to silence a failure without explicit handling inside the block. If you are using it just to suppress crashes, you have hidden a bug, not fixed one. Always handle errors inside the block, not just around it.
Passing Job or Deferred references across scope boundaries. This puts exception-handling responsibility in the wrong place. Use Flow<Result<T>> instead.
Conclusion
Most production coroutines bugs are not race conditions — they are scope boundary mismatches. SupervisorJob belongs at scope roots (viewModelScope, applicationScope), not sprinkled through business logic to suppress exceptions you have not handled.
The docs do not emphasize this clearly enough, but the Job hierarchy is a strict runtime contract. Respect it and structured concurrency does exactly what it promises: no silent failures, no missing error states, no production incidents from disappearing coroutines.
Further reading: Kotlin coroutines guide — Structured concurrency · Exception handling and supervision