{"id":620,"date":"2026-09-29T14:20:00","date_gmt":"2026-09-29T12:20:00","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=620"},"modified":"2026-09-26T15:27:28","modified_gmt":"2026-09-26T13:27:28","slug":"viewmodelscope-launch-supervisorjob-crash","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/viewmodelscope-launch-supervisorjob-crash\/","title":{"rendered":"Why viewModelScope.launch(SupervisorJob()) Still Crashes Your App"},"content":{"rendered":"<blockquote><p>An <code>UploadViewModel<\/code> sends several photos at once. A teammate wrapped each upload in <code>launch(SupervisorJob())<\/code>. The goal was that one failed upload can&#8217;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&#8217;s wrong?<\/p><\/blockquote>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">class UploadViewModel(\n    private val repo: PhotoRepository\n) : ViewModel() {\n\n    private val _state = MutableStateFlow(UploadState())\n    val state: StateFlow&lt;UploadState&gt; = _state\n\n    fun upload(photos: List&lt;Photo&gt;) {\n        photos.forEach { photo -&gt;\n            \/\/ \"Isolate\" each upload so one failure can't kill the others\n            viewModelScope.launch(SupervisorJob()) {\n                val url = repo.upload(photo) \/\/ throws IOException on a bad connection\n                _state.update { it.withUploaded(photo.id, url) }\n            }\n        }\n    }\n}<\/pre>\n\n\n<p>Passing <code>SupervisorJob()<\/code> to <code>launch<\/code> doesn&#8217;t make a coroutine safer. It replaces the coroutine&#8217;s parent. The new coroutine is no longer a child of <code>viewModelScope<\/code>. Its only parent is a fresh <code>SupervisorJob<\/code> that nothing else knows about. Two bugs follow from that. First, a failure still crashes the app. A <code>SupervisorJob<\/code> doesn&#8217;t handle a child&#8217;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 <code>viewModelScope<\/code> can&#8217;t reach a coroutine it isn&#8217;t the parent of. The kotlinx.coroutines <code>launch<\/code> docs call passing a <code>Job<\/code> here incorrect, because it &#8220;breaks structured concurrency.&#8221; The fix is to drop the argument. Then catch the exception inside the coroutine. <code>viewModelScope<\/code> is built on a <code>SupervisorJob<\/code>, so it already isolates its children.<\/p>\n\n<h2 class=\"wp-block-heading\">What launch(SupervisorJob()) actually does to the parent<\/h2>\n\n<p>Most candidates read <code>launch(SupervisorJob())<\/code> as &#8220;launch this with supervisor behavior.&#8221; That isn&#8217;t how a coroutine context works for <code>Job<\/code>. Every coroutine creates its own <code>Job<\/code>. A <code>Job<\/code> passed in the context doesn&#8217;t become the coroutine&#8217;s job. It becomes its parent.<\/p>\n\n<p>The <a href=\"https:\/\/kotlinlang.org\/api\/kotlinx.coroutines\/kotlinx-coroutines-core\/kotlinx.coroutines\/launch.html\" target=\"_blank\" rel=\"noopener\">kotlinx.coroutines <code>launch<\/code> reference<\/a> spells this out. Normally the <code>Job<\/code> in the scope&#8217;s context is used as the parent. &#8220;Passing a Job in context overrides the parent and is forbidden.&#8221; The same page lists what happens when you do it anyway. If the scope is cancelled, &#8220;the new coroutine will not be affected.&#8221; If the coroutine fails, the exception goes to the passed <code>Job<\/code> instead of the scope. And if that <code>Job<\/code> is a <code>SupervisorJob<\/code>, &#8220;the exception will be unhandled.&#8221;<\/p>\n\n<p>JetBrains added an IDE inspection for this pattern. It&#8217;s called &#8220;Job used as an argument in a coroutine starter.&#8221; It&#8217;s available since IntelliJ IDEA 2025.3. JetBrains explains it in a <a href=\"https:\/\/blog.jetbrains.com\/idea\/2026\/03\/intellij-idea-s-new-kotlin-coroutine-inspections-explained\/\" target=\"_blank\" rel=\"noopener\">March 2026 post on the new coroutine inspections<\/a>. The <code>Job<\/code> argument &#8220;overrides <code>Job<\/code> from the scope and becomes a parent. This breaks structured concurrency.&#8221;<\/p>\n\n<h2 class=\"wp-block-heading\">Bug 1: SupervisorJob() doesn&#8217;t stop the crash<\/h2>\n\n<p>A <code>SupervisorJob<\/code> changes one thing. A failing child doesn&#8217;t cancel the supervisor or its other children. The <a href=\"https:\/\/kotlinlang.org\/docs\/exception-handling.html#supervision\" target=\"_blank\" rel=\"noopener\">Kotlin exception-handling guide<\/a> is explicit about the cost. &#8220;Every child should handle its exceptions by itself.&#8221; The child&#8217;s exception goes to a <code>CoroutineExceptionHandler<\/code> if one is in the context. There isn&#8217;t one here.<\/p>\n\n<p>With no handler, the <a href=\"https:\/\/kotlinlang.org\/api\/kotlinx.coroutines\/kotlinx-coroutines-core\/kotlinx.coroutines\/-coroutine-exception-handler\/index.html\" target=\"_blank\" rel=\"noopener\"><code>CoroutineExceptionHandler<\/code> reference<\/a> describes the JVM fallback. The current thread&#8217;s <code>Thread.uncaughtExceptionHandler<\/code> is invoked. On Android, that&#8217;s a crash. The <a href=\"https:\/\/developer.android.com\/topic\/performance\/vitals\/crash\" target=\"_blank\" rel=\"noopener\">Android vitals crash page<\/a> says an unhandled exception crashes the app.<\/p>\n\n<p><code>viewModelScope<\/code> is already built on a <code>SupervisorJob<\/code>. Here is <code>createViewModelScope()<\/code> from <a href=\"https:\/\/github.com\/androidx\/androidx\/blob\/androidx-main\/lifecycle\/lifecycle-viewmodel\/src\/commonMain\/kotlin\/androidx\/lifecycle\/viewmodel\/internal\/CloseableCoroutineScope.kt\" target=\"_blank\" rel=\"noopener\"><code>CloseableCoroutineScope.kt<\/code><\/a> in androidx-main:<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">internal fun createViewModelScope(): CloseableCoroutineScope {\n    val dispatcher = try {\n        Dispatchers.Main.immediate\n    } catch (_: NotImplementedError) {\n        EmptyCoroutineContext\n    } catch (_: IllegalStateException) {\n        EmptyCoroutineContext\n    }\n    return CloseableCoroutineScope(coroutineContext = dispatcher + SupervisorJob())\n}<\/pre>\n\n\n<p>So a plain <code>viewModelScope.launch { }<\/code> 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 <code>SupervisorJob()<\/code> changed nothing about failures. It only broke cancellation.<\/p>\n\n<h2 class=\"wp-block-heading\">Bug 2: the upload outlives the ViewModel<\/h2>\n\n<p>When the ViewModel is cleared, <code>viewModelScope<\/code> is cancelled. That cancellation travels down to the scope&#8217;s children. These uploads aren&#8217;t its children anymore. Their parent is a <code>SupervisorJob<\/code> that nobody ever cancels.<\/p>\n\n<p>So each upload runs to completion after the user has left. The lambda still holds <code>repo<\/code> and <code>_state<\/code>. That keeps the cleared ViewModel reachable until the upload finishes. It then writes into a <code>StateFlow<\/code> no screen collects anymore. If the upload later fails, it still reaches the uncaught handler. That&#8217;s a crash from a screen the user already closed.<\/p>\n\n<h2 class=\"wp-block-heading\">The behavior, reproduced<\/h2>\n\n<p>This small JVM program rebuilds <code>viewModelScope<\/code> the same way, <code>SupervisorJob()<\/code> plus a single &#8220;main&#8221; thread. It installs a default uncaught handler that prints instead of killing the process. It runs on kotlinx.coroutines 1.10.2.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">val mainThread = Executors.newSingleThreadExecutor { r -&gt; Thread(r, \"main\") }\n    .asCoroutineDispatcher()\nfun fakeViewModelScope() = CoroutineScope(SupervisorJob() + mainThread)\n\nfun main() = runBlocking {\n    Thread.setDefaultUncaughtExceptionHandler { t, e -&gt;\n        println(\"  !! uncaught on '${t.name}': $e  (on Android: process dies)\")\n    }\n\n    println(\"1: failure inside launch(SupervisorJob())\")\n    val s1 = fakeViewModelScope()\n    try {\n        s1.launch(SupervisorJob()) { throw IllegalStateException(\"upload failed\") }\n    } catch (e: Exception) {\n        println(\"  caught by try\/catch: $e\") \/\/ never prints\n    }\n    delay(200)\n\n    println(\"2: scope cancelled while launch(SupervisorJob()) runs\")\n    val s2 = fakeViewModelScope()\n    val job = s2.launch(SupervisorJob()) {\n        repeat(5) { i -&gt;\n            delay(100)\n            println(\"  still uploading chunk $i, scope active=${s2.isActive}\")\n        }\n    }\n    delay(150); s2.cancel(); println(\"  scope cancelled\")\n    job.join()\n\n    println(\"3: plain launch fails, sibling in the same scope\")\n    val s3 = fakeViewModelScope()\n    s3.launch { delay(50); throw IllegalStateException(\"upload failed\") }\n    s3.launch { delay(200); println(\"  sibling finished, scope active=${s3.isActive}\") }\n        .join()\n    mainThread.close()\n}<\/pre>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">1: failure inside launch(SupervisorJob())\n  !! uncaught on 'main': java.lang.IllegalStateException: upload failed  (on Android: process dies)\n2: scope cancelled while launch(SupervisorJob()) runs\n  still uploading chunk 0, scope active=true\n  scope cancelled\n  still uploading chunk 1, scope active=false\n  still uploading chunk 2, scope active=false\n  still uploading chunk 3, scope active=false\n  still uploading chunk 4, scope active=false\n3: plain launch fails, sibling in the same scope\n  !! uncaught on 'main': java.lang.IllegalStateException: upload failed  (on Android: process dies)\n  sibling finished, scope active=true<\/pre>\n\n\n<p>Case 1 shows the crash path. The <code>try<\/code>\/<code>catch<\/code> around <code>launch<\/code> never fires. <code>launch<\/code> doesn&#8217;t rethrow its body&#8217;s exception to the caller. Case 2 shows four chunks uploading after the scope was cancelled. Case 3 is the plain <code>launch<\/code> the teammate replaced. The sibling survives. The failure still reaches the uncaught handler. The crash behavior is identical. Cancellation still works too.<\/p>\n\n<h2 class=\"wp-block-heading\">The fix: drop the argument and catch inside the coroutine<\/h2>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">fun upload(photos: List&lt;Photo&gt;) {\n    photos.forEach { photo -&gt;\n        viewModelScope.launch {\n            try {\n                val url = repo.upload(photo)\n                _state.update { it.withUploaded(photo.id, url) }\n            } catch (e: IOException) {\n                _state.update { it.withFailed(photo.id, e) }\n            }\n        }\n    }\n}<\/pre>\n\n\n<p>Each upload is a child of <code>viewModelScope<\/code> again. Leaving the screen cancels all of them. Siblings stay isolated because the scope&#8217;s own <code>SupervisorJob<\/code> already does that. The failure is handled where it happens, turned into UI state instead of a crash. This matches the <a href=\"https:\/\/developer.android.com\/kotlin\/coroutines\/coroutines-best-practices\" target=\"_blank\" rel=\"noopener\">Android coroutines best-practices page<\/a>. It says to catch likely exceptions inside the coroutine body. That applies to &#8220;any coroutines created with <code>viewModelScope<\/code> or <code>lifecycleScope<\/code>.&#8221;<\/p>\n\n<p>Catch <code>IOException<\/code>, not <code>Exception<\/code>. A broad <code>catch (e: Exception)<\/code> also catches the <code>CancellationException<\/code> thrown when the user leaves. That turns a normal cancellation into a fake &#8220;upload failed&#8221; state.<\/p>\n\n<p>If the parallel uploads live in a suspend function instead, use <code>supervisorScope { }<\/code>. It&#8217;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&#8217;s cancellation still reaches them. The children still need their own <code>try<\/code>\/<code>catch<\/code>.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">suspend fun uploadAll(photos: List&lt;Photo&gt;): List&lt;UploadResult&gt; = supervisorScope {\n    photos.map { photo -&gt;\n        async {\n            try {\n                UploadResult.Success(photo.id, repo.upload(photo))\n            } catch (e: IOException) {\n                UploadResult.Failed(photo.id, e)\n            }\n        }\n    }.awaitAll()\n}<\/pre>\n\n\n<p>What if the upload really must finish after the user leaves? A detached <code>SupervisorJob<\/code> is still the wrong tool. The best-practices page recommends an external <code>CoroutineScope<\/code> for that. Something that lives longer than the screen should own it. The <code>Application<\/code> class is one option. The detachment is then deliberate and has one owner.<\/p>\n\n<h2 class=\"wp-block-heading\">The rule to remember<\/h2>\n\n<p><strong>Don&#8217;t pass <code>Job()<\/code> or <code>SupervisorJob()<\/code> to <code>launch<\/code>, <code>async<\/code> or <code>withContext<\/code>. It picks a new parent. It doesn&#8217;t change behavior.<\/strong><\/p>\n\n<ul>\n<li>Want sibling isolation in a ViewModel? You already have it. <code>viewModelScope<\/code> is built on a <code>SupervisorJob<\/code>.<\/li>\n<li>Want sibling isolation inside a suspend function? Use <code>supervisorScope { }<\/code>.<\/li>\n<li>Want no crash? Catch the exception inside the coroutine, or install a <code>CoroutineExceptionHandler<\/code>. A supervisor alone does neither.<\/li>\n<li>Want work to outlive the screen? Inject a longer-lived scope with a clear owner.<\/li>\n<\/ul>\n\n<h2 class=\"wp-block-heading\">How to answer this in an interview<\/h2>\n\n<ol>\n<li>Start with the parent, not the exception. Say that a <code>Job<\/code> in the context argument becomes the new coroutine&#8217;s parent. The coroutine leaves <code>viewModelScope<\/code>&#8216;s hierarchy.<\/li>\n<li>Name both consequences. Failures are unhandled under a <code>SupervisorJob<\/code>, so the app still crashes. Cancelling <code>viewModelScope<\/code> no longer reaches the coroutine, so it leaks past <code>onCleared()<\/code>.<\/li>\n<li>Point out the redundancy. <code>viewModelScope<\/code> already uses a <code>SupervisorJob<\/code>. The argument added nothing it was meant to add.<\/li>\n<li>Give the fix as a rule. Use a plain <code>launch<\/code> with a specific <code>catch<\/code> inside the body. Use <code>supervisorScope<\/code> for parallel work in a suspend function.<\/li>\n<\/ol>\n\n<p>This bug fits the spot-the-bug format of the <a href=\"https:\/\/grindloop.ai\/blog\/android-machine-coding-round-three-formats\/\">Android live-coding round<\/a>. It rewards reading the context argument before the body.<\/p>\n\n<p><strong>Common wrong answers:<\/strong><\/p>\n<ul>\n<li>&#8220;<code>SupervisorJob<\/code> catches the exception, so it won&#8217;t crash.&#8221; It doesn&#8217;t catch anything. It only stops the failure from cancelling siblings.<\/li>\n<li>&#8220;Wrap the <code>launch<\/code> call in <code>try<\/code>\/<code>catch<\/code>.&#8221; <code>launch<\/code> never rethrows its body&#8217;s exception to the caller. That holds even on <code>Dispatchers.Main.immediate<\/code>, where the body starts right away. The coroutine catches the exception and sends it up the <code>Job<\/code> hierarchy.<\/li>\n<li>&#8220;Swap it for <code>launch(Job())<\/code>.&#8221; A plain <code>Job<\/code> has the same detachment problem. Any <code>Job<\/code> in the context becomes the parent.<\/li>\n<li>&#8220;Use <code>GlobalScope<\/code> so it finishes.&#8221; That makes the detachment permanent and gives the work no owner at all.<\/li>\n<\/ul>\n\n<hr\/>\n\n<p><em>Related: <a href=\"https:\/\/grindloop.ai\/blog\/viewmodelscope-async-swallows-api-call\/\">why viewModelScope.async swallows a failed API call<\/a>. There the exception disappears instead of crashing. GrindLoop&#8217;s Coroutines track turns bugs like this one into live debugging drills. Each drill comes with a reviewed fix.<\/em><\/p>\n\n<p><strong>Failed the interview? Not the next one.<\/strong><\/p>","protected":false},"excerpt":{"rendered":"<p>Passing SupervisorJob() to launch in viewModelScope doesn&#8217;t stop a crash, and it detaches the coroutine from onCleared(). Here&#8217;s the two-bug reason, a runnable repro, the fix, and the interview answer.<\/p>\n","protected":false},"author":2,"featured_media":622,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Why launch(SupervisorJob()) Still Crashes Your Android App","rank_math_description":"Passing SupervisorJob() to launch in viewModelScope still crashes your app and outlives onCleared(). The two-bug reason, a runnable repro, and the fix.","rank_math_focus_keyword":"SupervisorJob,structured concurrency","footnotes":""},"categories":[9,3],"tags":[15,70,13,87,16,12,32,88,45],"class_list":["post-620","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-bug-squash","category-coroutines","tag-android","tag-concurrency","tag-coroutines","tag-debugging-interview","tag-interview-prep","tag-kotlin","tag-lifecycle","tag-structured-concurrency","tag-technical-interview"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/620","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/comments?post=620"}],"version-history":[{"count":1,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/620\/revisions"}],"predecessor-version":[{"id":621,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/620\/revisions\/621"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/622"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=620"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=620"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=620"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}