Why viewModelScope.launch(SupervisorJob()) Still Crashes Your App

An UploadViewModel sends several photos at once. A teammate wrapped each upload in launch(SupervisorJob()). The goal was that one failed upload can’t take down the rest. Two bug reports come back from QA. On a flaky connection, a failed upload still crashes the app. And if the user backs out mid-upload, the uploads keep running. What’s wrong?

class UploadViewModel(
    private val repo: PhotoRepository
) : ViewModel() {

    private val _state = MutableStateFlow(UploadState())
    val state: StateFlow<UploadState> = _state

    fun upload(photos: List<Photo>) {
        photos.forEach { photo ->
            // "Isolate" each upload so one failure can't kill the others
            viewModelScope.launch(SupervisorJob()) {
                val url = repo.upload(photo) // throws IOException on a bad connection
                _state.update { it.withUploaded(photo.id, url) }
            }
        }
    }
}

Passing SupervisorJob() to launch doesn’t make a coroutine safer. It replaces the coroutine’s parent. The new coroutine is no longer a child of viewModelScope. Its only parent is a fresh SupervisorJob that nothing else knows about. Two bugs follow from that. First, a failure still crashes the app. A SupervisorJob doesn’t handle a child’s exception. It passes it on as an uncaught exception. On Android, an uncaught exception ends the process. Second, the upload no longer stops when the ViewModel is cleared. Cancelling viewModelScope can’t reach a coroutine it isn’t the parent of. The kotlinx.coroutines launch docs call passing a Job here incorrect, because it “breaks structured concurrency.” The fix is to drop the argument. Then catch the exception inside the coroutine. viewModelScope is built on a SupervisorJob, so it already isolates its children.

What launch(SupervisorJob()) actually does to the parent

Most candidates read launch(SupervisorJob()) as “launch this with supervisor behavior.” That isn’t how a coroutine context works for Job. Every coroutine creates its own Job. A Job passed in the context doesn’t become the coroutine’s job. It becomes its parent.

The kotlinx.coroutines launch reference spells this out. Normally the Job in the scope’s context is used as the parent. “Passing a Job in context overrides the parent and is forbidden.” The same page lists what happens when you do it anyway. If the scope is cancelled, “the new coroutine will not be affected.” If the coroutine fails, the exception goes to the passed Job instead of the scope. And if that Job is a SupervisorJob, “the exception will be unhandled.”

JetBrains added an IDE inspection for this pattern. It’s called “Job used as an argument in a coroutine starter.” It’s available since IntelliJ IDEA 2025.3. JetBrains explains it in a March 2026 post on the new coroutine inspections. The Job argument “overrides Job from the scope and becomes a parent. This breaks structured concurrency.”

Bug 1: SupervisorJob() doesn’t stop the crash

A SupervisorJob changes one thing. A failing child doesn’t cancel the supervisor or its other children. The Kotlin exception-handling guide is explicit about the cost. “Every child should handle its exceptions by itself.” The child’s exception goes to a CoroutineExceptionHandler if one is in the context. There isn’t one here.

With no handler, the CoroutineExceptionHandler reference describes the JVM fallback. The current thread’s Thread.uncaughtExceptionHandler is invoked. On Android, that’s a crash. The Android vitals crash page says an unhandled exception crashes the app.

viewModelScope is already built on a SupervisorJob. Here is createViewModelScope() from CloseableCoroutineScope.kt in androidx-main:

internal fun createViewModelScope(): CloseableCoroutineScope {
    val dispatcher = try {
        Dispatchers.Main.immediate
    } catch (_: NotImplementedError) {
        EmptyCoroutineContext
    } catch (_: IllegalStateException) {
        EmptyCoroutineContext
    }
    return CloseableCoroutineScope(coroutineContext = dispatcher + SupervisorJob())
}

So a plain viewModelScope.launch { } already has supervisor behavior toward its siblings. One failed upload never cancelled the others. It also already crashed the app, because the same rule applies. The extra SupervisorJob() changed nothing about failures. It only broke cancellation.

Bug 2: the upload outlives the ViewModel

When the ViewModel is cleared, viewModelScope is cancelled. That cancellation travels down to the scope’s children. These uploads aren’t its children anymore. Their parent is a SupervisorJob that nobody ever cancels.

So each upload runs to completion after the user has left. The lambda still holds repo and _state. That keeps the cleared ViewModel reachable until the upload finishes. It then writes into a StateFlow no screen collects anymore. If the upload later fails, it still reaches the uncaught handler. That’s a crash from a screen the user already closed.

The behavior, reproduced

This small JVM program rebuilds viewModelScope the same way, SupervisorJob() plus a single “main” thread. It installs a default uncaught handler that prints instead of killing the process. It runs on kotlinx.coroutines 1.10.2.

val mainThread = Executors.newSingleThreadExecutor { r -> Thread(r, "main") }
    .asCoroutineDispatcher()
fun fakeViewModelScope() = CoroutineScope(SupervisorJob() + mainThread)

fun main() = runBlocking {
    Thread.setDefaultUncaughtExceptionHandler { t, e ->
        println("  !! uncaught on '${t.name}': $e  (on Android: process dies)")
    }

    println("1: failure inside launch(SupervisorJob())")
    val s1 = fakeViewModelScope()
    try {
        s1.launch(SupervisorJob()) { throw IllegalStateException("upload failed") }
    } catch (e: Exception) {
        println("  caught by try/catch: $e") // never prints
    }
    delay(200)

    println("2: scope cancelled while launch(SupervisorJob()) runs")
    val s2 = fakeViewModelScope()
    val job = s2.launch(SupervisorJob()) {
        repeat(5) { i ->
            delay(100)
            println("  still uploading chunk $i, scope active=${s2.isActive}")
        }
    }
    delay(150); s2.cancel(); println("  scope cancelled")
    job.join()

    println("3: plain launch fails, sibling in the same scope")
    val s3 = fakeViewModelScope()
    s3.launch { delay(50); throw IllegalStateException("upload failed") }
    s3.launch { delay(200); println("  sibling finished, scope active=${s3.isActive}") }
        .join()
    mainThread.close()
}
1: failure inside launch(SupervisorJob())
  !! uncaught on 'main': java.lang.IllegalStateException: upload failed  (on Android: process dies)
2: scope cancelled while launch(SupervisorJob()) runs
  still uploading chunk 0, scope active=true
  scope cancelled
  still uploading chunk 1, scope active=false
  still uploading chunk 2, scope active=false
  still uploading chunk 3, scope active=false
  still uploading chunk 4, scope active=false
3: plain launch fails, sibling in the same scope
  !! uncaught on 'main': java.lang.IllegalStateException: upload failed  (on Android: process dies)
  sibling finished, scope active=true

Case 1 shows the crash path. The try/catch around launch never fires. launch doesn’t rethrow its body’s exception to the caller. Case 2 shows four chunks uploading after the scope was cancelled. Case 3 is the plain launch the teammate replaced. The sibling survives. The failure still reaches the uncaught handler. The crash behavior is identical. Cancellation still works too.

The fix: drop the argument and catch inside the coroutine

fun upload(photos: List<Photo>) {
    photos.forEach { photo ->
        viewModelScope.launch {
            try {
                val url = repo.upload(photo)
                _state.update { it.withUploaded(photo.id, url) }
            } catch (e: IOException) {
                _state.update { it.withFailed(photo.id, e) }
            }
        }
    }
}

Each upload is a child of viewModelScope again. Leaving the screen cancels all of them. Siblings stay isolated because the scope’s own SupervisorJob already does that. The failure is handled where it happens, turned into UI state instead of a crash. This matches the Android coroutines best-practices page. It says to catch likely exceptions inside the coroutine body. That applies to “any coroutines created with viewModelScope or lifecycleScope.”

Catch IOException, not Exception. A broad catch (e: Exception) also catches the CancellationException thrown when the user leaves. That turns a normal cancellation into a fake “upload failed” state.

If the parallel uploads live in a suspend function instead, use supervisorScope { }. It’s the structured version of what the teammate wanted. Per the Kotlin guide, it propagates cancellation in one direction only. It also waits for all its children. The caller’s cancellation still reaches them. The children still need their own try/catch.

suspend fun uploadAll(photos: List<Photo>): List<UploadResult> = supervisorScope {
    photos.map { photo ->
        async {
            try {
                UploadResult.Success(photo.id, repo.upload(photo))
            } catch (e: IOException) {
                UploadResult.Failed(photo.id, e)
            }
        }
    }.awaitAll()
}

What if the upload really must finish after the user leaves? A detached SupervisorJob is still the wrong tool. The best-practices page recommends an external CoroutineScope for that. Something that lives longer than the screen should own it. The Application class is one option. The detachment is then deliberate and has one owner.

The rule to remember

Don’t pass Job() or SupervisorJob() to launch, async or withContext. It picks a new parent. It doesn’t change behavior.

  • Want sibling isolation in a ViewModel? You already have it. viewModelScope is built on a SupervisorJob.
  • Want sibling isolation inside a suspend function? Use supervisorScope { }.
  • Want no crash? Catch the exception inside the coroutine, or install a CoroutineExceptionHandler. A supervisor alone does neither.
  • Want work to outlive the screen? Inject a longer-lived scope with a clear owner.

How to answer this in an interview

  1. Start with the parent, not the exception. Say that a Job in the context argument becomes the new coroutine’s parent. The coroutine leaves viewModelScope‘s hierarchy.
  2. Name both consequences. Failures are unhandled under a SupervisorJob, so the app still crashes. Cancelling viewModelScope no longer reaches the coroutine, so it leaks past onCleared().
  3. Point out the redundancy. viewModelScope already uses a SupervisorJob. The argument added nothing it was meant to add.
  4. Give the fix as a rule. Use a plain launch with a specific catch inside the body. Use supervisorScope for parallel work in a suspend function.

This bug fits the spot-the-bug format of the Android live-coding round. It rewards reading the context argument before the body.

Common wrong answers:

  • “SupervisorJob catches the exception, so it won’t crash.” It doesn’t catch anything. It only stops the failure from cancelling siblings.
  • “Wrap the launch call in try/catch.” launch never rethrows its body’s exception to the caller. That holds even on Dispatchers.Main.immediate, where the body starts right away. The coroutine catches the exception and sends it up the Job hierarchy.
  • “Swap it for launch(Job()).” A plain Job has the same detachment problem. Any Job in the context becomes the parent.
  • “Use GlobalScope so it finishes.” That makes the detachment permanent and gives the work no owner at all.

Related: why viewModelScope.async swallows a failed API call. There the exception disappears instead of crashing. GrindLoop’s Coroutines track turns bugs like this one into live debugging drills. Each drill comes with a reviewed fix.

Failed the interview? Not the next one.

Leave a Comment