DEV Community

YADNYESH RANA
YADNYESH RANA

Posted on

Your SupervisorJob Isn't Protecting What You Think It Is

Your SupervisorJob isn't protecting what you think it is.

The structured-concurrency pitch for SupervisorJob is simple: "a child's failure
doesn't cancel its siblings." Teams building a shared Kotlin Multiplatform module —
one ViewModel-equivalent class whose CoroutineScope backs both the Android screen
and the iOS view model via a Swift bridge — lean on exactly that promise to keep one
flaky network call from taking down every other in-flight operation.

It works right up until one of those "isolated" children does something extremely
common: opens its own coroutineScope { } inside itself to run two sub-tasks
concurrently. At that point the isolation quietly stops applying, and teams spend a
confusing afternoon staring at a crash log where the stack trace says "root cause:
analytics flush failed" but the actual symptom is the entire shared module's scope
getting cancelled — syncing, prefetching, the lot.

The setup that looks safe

class SharedSyncCoordinator(
    private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
    private val scope = CoroutineScope(SupervisorJob() + dispatcher)

    fun start() {
        scope.launch { syncUserProfile() }   // child A
        scope.launch { prefetchFeed() }      // child B
        scope.launch { flushAnalytics() }    // child C — least important, OK to fail
    }

    private suspend fun flushAnalytics() {
        // ... sends a batch, occasionally 500s
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the textbook pattern, and it genuinely works: if flushAnalytics() throws, A
and B keep running. SupervisorJob really does isolate its direct children from
each other's failures. The bug shows up the moment flushAnalytics() is refactored to
do two things at once instead of one:

private suspend fun flushAnalytics() = coroutineScope {   // <- the trap
    launch { sendBatch(highPriorityEvents) }
    launch { sendBatch(lowPriorityEvents) }
}
Enter fullscreen mode Exit fullscreen mode

coroutineScope { } creates a regular Job, not a supervisor, as the parent of
those two launch calls — and that's actually correct and intentional on its own:
coroutineScope is specifically designed so a failure in either branch cancels the
other and rethrows, because the two branches are meant to be treated as one atomic
unit of work. The part that surprises people is what happens one level up: that
rethrown exception propagates to flushAnalytics()'s caller exactly like any other
exception would. From SupervisorJob's point of view, child C (the scope.launch
that called flushAnalytics()) failed — which is exactly what SupervisorJob is
designed to tolerate. The isolation held at the level it was built for. The actual
mistake is a level deeper and easy to miss in review: nothing re-declared supervision
inside flushAnalytics() for its own two sub-batches, so a failure in the
low-priority batch takes the high-priority batch down with it, inside what the
original author assumed was already a "failures don't matter here" branch.

Why this is worse on a KMP bridge specifically

In a pure-Android app this is already a real bug, but it's usually contained to one
feature. In a shared KMP module exposed to Swift through a thin bridge, the blast
radius is bigger for two reasons:

  1. The Swift side can't see the structured-concurrency tree. It calls one Swift method, gets a callback or a Flow emission, and has no idea that failure in a "fire and forget" analytics call is coupled to the sync job it actually cares about. Debugging starts on the iOS side, where the stack trace is a Swift continuation, not the Kotlin coroutine hierarchy that actually explains it.
  2. One misbehaving leaf cancels the whole shared scope, not just one screen. If scope above is held at the shared-module singleton level (common when the module is the single source of truth for both platforms), the cancellation doesn't just kill flushAnalytics() — every other coroutine parented to that same scope, including ones backing UI on the other platform, gets torn down with it.

The fix: push SupervisorJob down to every fan-out point, not just the top

The rule isn't "use SupervisorJob once at the root." It's "every place where you
fan out into concurrent children whose failures genuinely shouldn't affect each other
needs its own supervisor — including nested ones":

private suspend fun flushAnalytics() = supervisorScope {   // <- fixed
    launch { sendBatch(highPriorityEvents) }
    launch { sendBatch(lowPriorityEvents) }
}
Enter fullscreen mode Exit fullscreen mode

supervisorScope gives you the same isolation guarantee one level deeper, scoped to
exactly the two batches that should genuinely be allowed to fail independently. The
outer SupervisorJob at the coordinator level is still doing its job — it's just no
longer the only line of defense, which is what it was being asked to be.

A cheap way to catch this class of bug before it reaches production: grep the shared
module for every coroutineScope { call and ask, for each one, "would I actually be
upset if sibling B failing also failed sibling A?" If the honest answer is no, it's a
supervisorScope, not a coroutineScope — no exceptions for "it's just analytics."


This is one of four concurrency rules covered on the Core Concurrency Rules page of
the free Kotlin Multiplatform & Concurrency Developer Cheat Sheet —
alongside Dispatchers.Default/Dispatchers.IO thread pool boundaries, work-stealing
behavior, and the exact propagation limits a SupervisorJob does and doesn't give you
across a Kotlin/Swift bridge. Free download, name-your-price if you find it useful.

Top comments (0)