Engineering

One failed child cancelled the whole screen, and the scope was the reason

Structured concurrency propagates failure upward by design. SupervisorJob is how you declare independence.

MobileByteSensei 9 min read
kmpcoroutinesarchitecture

TL;DR

In structured concurrency, a child that fails cancels its parent, and the parent cancels every sibling. That is not a bug; it is the property that stops orphaned work.

It also means one failing widget takes down the screen. A dashboard loading five independent panels in one scope loses all five when the least important one 500s — and the scope dies, so retry does nothing.

SupervisorJob is how you say "these children are independent". The decision is per group of siblings, so where you put the supervisor is the whole design, and coroutineScope versus supervisorScope is the same decision spelled locally.

The case this post is about

A dashboard with five panels, each from a different endpoint:

// commonMain
class DashboardViewModel(private val api: Api) {

    private val scope = CoroutineScope(Job() + Dispatchers.Default)   // ← plain Job

    fun load() {
        scope.launch { state.update { it.copy(balance  = api.balance())  } }
        scope.launch { state.update { it.copy(activity = api.activity()) } }
        scope.launch { state.update { it.copy(goals    = api.goals())    } }
        scope.launch { state.update { it.copy(news     = api.news())     } }
        scope.launch { state.update { it.copy(tips     = api.tips())     } }
    }
}

Five independent launches. Each writes its own slice of state. No shared dependency between them — the panels have nothing to do with each other, which is precisely the point of loading them separately.

The symptom

A blank dashboard whenever one endpoint had a bad day:

GET /balance    200
GET /activity   200
GET /goals      200
GET /news       200
GET /tips       503        ← the least important panel on the screen

Result: every panel empty. Not "tips empty" — all five, including the four that returned 200 with data already in flight.

Worse, the retry button did nothing:

tap "Retry"  → load() called → nothing happens, no requests in the log

And the crash, which is how it was finally understood:

kotlinx.coroutines.JobCancellationException: Parent job is Cancelling
  Caused by: HttpException: 503 Service Unavailable at /tips

"Parent job is Cancelling" on four coroutines that had nothing to do with /tips.

Why the obvious fix failed

"Wrap each call in try/catch." This does work, and it was my fix for a day:

scope.launch { runCatching { api.tips() }.onSuccess { … } }

Five try/catches is five chances to forget one, and the sixth panel added next quarter will forget it. It also conflates two different things — handling an error for display and preventing cancellation propagation — so the code gives no signal about which concern each block serves.

"Use async and awaitAll." Makes it strictly worse. awaitAll fails fast on the first failure, and async in a plain-Job scope still propagates to the parent at launch time, so you get the same cancellation plus a more complicated call site.

"Catch at the scope with a CoroutineExceptionHandler." The handler observes an unhandled exception; it does not stop propagation. The siblings are already cancelled by the time it runs. I added one, got a nice log line, and the screen was still blank.

"Recreate the scope in load()." Fixes the dead retry button by papering over the reason the scope died. It also leaks: the old scope's coroutines may still be running, and now nothing holds a reference to cancel them.

The actual mechanism

A Job is a node in a tree, and failure travels both directions by design.

A child's failure cancels its parent. When a coroutine throws something other than CancellationException, it completes exceptionally and notifies its parent job, which also fails.

A cancelled parent cancels all its children. So the failure goes up one level, then back down to every sibling.

That round trip is worth tracing on this exact case, because "the least important panel 503'd" and "the screen went blank" are separated by four mechanical steps and no decision anywhere:

api.tips() throws HttpException(503)
  → that child's Job completes exceptionally
  → notifies the parent (the plain Job in `scope`) → parent FAILS
  → parent cancels all children → balance, activity, goals, news cancelled mid-flight
  → parent Job is now terminal

Nothing in that chain is a bug, and none of it is configurable from the call site. The fourth line is why the four healthy panels show nothing — they did not fail, they were cancelled — which is also why their own error handling never ran: CancellationException is specifically the one thing structured concurrency does not treat as an error.

That is worth sitting with, because it is what makes the first attempted fix above insufficient rather than merely incomplete. Four of the five coroutines were cancelled, not failed, so a runCatching around each would catch nothing on those four — and the panels would still be empty.

A failed Job is terminal. This is the part that surprises people and explains the dead retry button: a cancelled or failed Job cannot be reused. scope.launch on a dead scope returns a job that is already cancelled — silently, no exception — so load() genuinely did nothing.

CancellationException is the exception to all of this: it means "this was cancelled on purpose" and propagates downward only, which is why you must re-throw it rather than swallow it in a catch (e: Throwable).

A plain Job scope with five children: one child failing notifies the parent, the parent fails, and the cancellation travels back down to all four healthy siblings — leaving a terminal scope where subsequent launches are silently already-cancelled. Beside it, a SupervisorJob scope where the same failure stops at the supervisor and the four siblings continue.

SupervisorJob changes exactly one rule: a child's failure does not cancel the supervisor, so it never travels back down. Downward cancellation still works — cancelling the scope still cancels every child, which is the behaviour you want for leaving a screen.

So:

Failure propagation is a property of the parent job, not of the child. Which means it is a decision about a group of siblings: are these one unit of work that fails together, or independent units that fail alone?

The fix

Declare the independence at the scope, and keep the failure handling per panel because that is a genuinely separate concern.

// commonMain
class DashboardViewModel(private val api: Api) {

    // SupervisorJob: these panels are independent. Cancelling the scope still
    // cancels all of them — only upward failure propagation is disabled.
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

    fun load() {
        panel({ api.balance()  }) { s, v -> s.copy(balance  = v) }
        panel({ api.activity() }) { s, v -> s.copy(activity = v) }
        panel({ api.goals()    }) { s, v -> s.copy(goals    = v) }
        panel({ api.news()     }) { s, v -> s.copy(news     = v) }
        panel({ api.tips()     }) { s, v -> s.copy(tips     = v) }
    }

    private fun <T> panel(
        fetch: suspend () -> T,
        apply: (DashboardState, Loadable<T>) -> DashboardState,
    ) = scope.launch {
        try {
            val v = fetch()
            state.update { apply(it, Loadable.Ready(v)) }
        } catch (e: CancellationException) {
            throw e                                   // never swallow a cancel
        } catch (e: Throwable) {
            state.update { apply(it, Loadable.Failed(e.message)) }
        }
    }

    fun clear() = scope.cancel()
}

Three things this gets right that the try/catch-only version did not:

The supervisor is the structural guarantee; the catch is the user experience. Even if someone adds a sixth panel and forgets the try, the other five survive — the supervisor handles isolation, the catch handles showing the failure. Two concerns, two mechanisms.

One failing panel renders as a failed panel. Loadable.Failed means the UI can show "couldn't load tips — retry" in that one card, which is what the user should see. The old code could not express a per-panel failure at all.

The scope survives, so retry works. No recreation, no leak.

And where work genuinely is one unit, use the local form rather than a supervisor:

// Profile needs BOTH the user and their permissions. Either failing means
// the screen cannot render — so fail together, deliberately.
suspend fun loadProfile(): Profile = coroutineScope {
    val user  = async { api.user() }
    val perms = async { api.permissions() }
    Profile(user.await(), perms.await())      // one failure cancels the other
}

coroutineScope versus supervisorScope is the same decision, scoped to one function. The useful habit is to ask it at every fan-out: is this all-or-nothing?

How we proved it

The claim has two halves — siblings survive, and the scope stays usable — and both need asserting because the second is the one that bit us twice.

// commonTest
@Test
fun oneFailedPanelDoesNotCancelTheOthers() = runTest {
    val api = FakeApi(tipsFails = true)
    val vm = DashboardViewModel(api)

    vm.load()
    advanceUntilIdle()

    val s = vm.state.value
    assertIs<Loadable.Ready<*>>(s.balance)
    assertIs<Loadable.Ready<*>>(s.activity)
    assertIs<Loadable.Ready<*>>(s.goals)
    assertIs<Loadable.Ready<*>>(s.news)
    assertIs<Loadable.Failed>(s.tips)        // the one that actually failed
}

@Test
fun scopeSurvivesAFailureSoRetryWorks() = runTest {
    val api = FakeApi(tipsFails = true)
    val vm = DashboardViewModel(api)
    vm.load(); advanceUntilIdle()

    api.tipsFails = false
    vm.load(); advanceUntilIdle()             // ← no-op on a dead scope

    assertIs<Loadable.Ready<*>>(vm.state.value.tips)
}
with Job()            test 1 FAILED (4 of 5 panels were Loading, not Ready)
                      test 2 FAILED (tips still Failed — no request was made)
with SupervisorJob()  test 1 PASSED
                      test 2 PASSED

The second test is the valuable one. It fails silently in the original design — no exception, no log, just a load() that does nothing — and that is the exact production symptom of the dead retry button.

And the downward direction, to confirm the supervisor did not cost us teardown:

@Test
fun cancellingTheScopeStillCancelsEveryChild() = runTest {
    val vm = DashboardViewModel(SlowApi())
    vm.load()
    vm.clear()
    advanceUntilIdle()
    assertEquals(0, SlowApi.inFlight)        // supervisor only blocks UPWARD propagation
}

That test exists because "use SupervisorJob" is often repeated without the caveat, and it is worth proving to yourself that you have not disabled cleanup.

One limit: these run under runTest's virtual time with a fake API, so they verify the structural semantics rather than behaviour under real concurrency and real network timing. For this class of bug that is the right level — the propagation rule is deterministic, and nothing about it depends on real dispatchers.

What this generalizes to

Failure-propagation policy belongs to the parent, so it is a decision about grouping. Any supervision tree — Erlang's, an actor hierarchy, a process group, a Kubernetes pod's containers — makes the same choice: which units fail together? Getting that grouping right is most of the design, and it is usually invisible in code that just launches things.

A failed container is often terminal, and silently so. The dead retry button is the sharpest part of this bug, because nothing reported it. Whenever you hold a long-lived container — a scope, a connection pool, a channel, a session — ask what state it is in after a failure, and whether operations on it still appear to work.

Isolation and error handling are different concerns. The try/catch made the error visible; the supervisor made the siblings survive. Solving one with the other leaves the gap for whoever adds the next sibling.

CancellationException is not a failure. Catching Throwable and swallowing it breaks cooperative cancellation everywhere it appears, and the symptom is work that will not stop. Re-throw it, always.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS