{"id":155,"date":"2026-09-22T10:15:00","date_gmt":"2026-09-22T08:15:00","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=155"},"modified":"2026-09-27T00:40:38","modified_gmt":"2026-09-26T22:40:38","slug":"viewmodelscope-async-swallows-api-call","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/viewmodelscope-async-swallows-api-call\/","title":{"rendered":"Why viewModelScope.async Swallows a Failed API Call"},"content":{"rendered":"<blockquote><p>A <code>ProfileViewModel<\/code> loads a profile and logs an analytics event when the screen opens. The analytics call starts failing on a flaky network path. There&#8217;s no crash, no log line and no Crashlytics entry. The dashboard just shows the event never fires. 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 ProfileViewModel(\n    private val api: ProfileApi,\n    private val analytics: AnalyticsApi\n) : ViewModel() {\n\n    private val _uiState = MutableStateFlow(ProfileUiState())\n    val uiState: StateFlow&lt;ProfileUiState&gt; = _uiState\n\n    fun loadProfile(userId: String) {\n        viewModelScope.launch {\n            _uiState.update { it.copy(profile = api.getProfile(userId)) }\n        }\n        viewModelScope.async { analytics.logProfileView(userId) } \/\/ fire-and-forget\n    }\n}<\/pre>\n\n\n<p>This viewModelScope async call is the problem. The analytics work runs in <code>viewModelScope.async<\/code>, and nobody calls <code>await()<\/code> on the result. An <code>async<\/code> started directly on a scope is a root coroutine. Kotlin&#8217;s rule for root coroutines is that <code>async<\/code> doesn&#8217;t report its exception. It stores it in the returned <code>Deferred<\/code> and waits for someone to call <code>await()<\/code>. Here the <code>Deferred<\/code> is thrown away, so the failure is never seen. A <code>CoroutineExceptionHandler<\/code> wouldn&#8217;t help either, since the docs say it has no effect on <code>async<\/code>. And <code>viewModelScope<\/code> runs on a <code>SupervisorJob<\/code>, so the failure doesn&#8217;t cancel anything else. The fix is to use <code>launch<\/code> for fire-and-forget work and handle the failure inside it. Keep <code>async<\/code> for work you will <code>await()<\/code>. One detail is easy to miss. Put the same <code>async<\/code> inside a <code>launch<\/code> and the behavior flips. It&#8217;s a child then, so its failure propagates and can crash the app.<\/p>\n\n<h2 class=\"wp-block-heading\">Why a viewModelScope async call hides the failure<\/h2>\n\n<p>Kotlin&#8217;s <a href=\"https:\/\/kotlinlang.org\/docs\/exception-handling.html\" target=\"_blank\" rel=\"noopener\">coroutine exception-handling guide<\/a> describes two kinds of builders. <code>launch<\/code> propagates exceptions automatically. <code>async<\/code> and <code>produce<\/code> expose them to the caller, who is expected to consume them, for example with <code>await()<\/code>. The guide applies this distinction to root coroutines, meaning ones that aren&#8217;t children of another coroutine. It also says a <code>CoroutineExceptionHandler<\/code> has no effect on <code>async<\/code>. The builder catches every exception and puts it in the <code>Deferred<\/code>.<\/p>\n\n<p><code>viewModelScope.async { ... }<\/code> creates exactly that kind of root coroutine. Its exception sits in a <code>Deferred<\/code> that no code keeps. Nothing logs it, rethrows it or reports it.<\/p>\n\n<h2 class=\"wp-block-heading\">Why nothing else notices<\/h2>\n\n<p><code>viewModelScope<\/code> is built on a <code>SupervisorJob<\/code>. Here&#8217;s <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>The same Kotlin guide says a child&#8217;s failure doesn&#8217;t propagate to a supervisor job or its other children. So the failed <code>async<\/code> doesn&#8217;t cancel the profile load or the scope. With no await, no handler and no cancellation, the failure leaves no trace.<\/p>\n\n<h2 class=\"wp-block-heading\">Inside a launch, the same async crashes instead<\/h2>\n\n<p>It&#8217;s a natural follow-up question. Move the fire-and-forget <code>async<\/code> inside a <code>launch<\/code>, and it&#8217;s no longer a root coroutine. It&#8217;s a child of that <code>launch<\/code>, which runs on a regular <code>Job<\/code>. A failing child cancels its parent, whether or not anyone awaits it. The <code>launch<\/code> then fails. Its exception reaches the scope&#8217;s <code>SupervisorJob<\/code>. With no handler installed, it goes to the thread&#8217;s uncaught exception handler. On Android, that crashes the app.<\/p>\n\n<p>This small JVM program rebuilds <code>viewModelScope<\/code> the same way, with a <code>SupervisorJob<\/code> and a single thread named &#8220;main.&#8221; It installs an uncaught handler that prints instead of exiting. It runs on kotlinx.coroutines 1.9.0.<\/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\nsuspend fun getProfile(): String { delay(200); return \"profile\" }\nsuspend fun logProfileView() { delay(50); throw IllegalStateException(\"analytics failed\") }\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: unawaited async inside viewModelScope.launch\")\n    val s1 = fakeViewModelScope()\n    val job1 = s1.launch {\n        val profile = async { getProfile() }\n        async { logProfileView() } \/\/ fire-and-forget\n        println(\"  profile = ${profile.await()}\")\n    }\n    job1.join()\n    println(\"  launch cancelled=${job1.isCancelled}, scope active=${s1.isActive}\")\n\n    println(\"2: unawaited async launched directly on the scope (root coroutine)\")\n    val s2 = fakeViewModelScope()\n    s2.async { logProfileView() }\n    delay(300)\n    println(\"  scope active=${s2.isActive} (nothing printed above = silently stored)\")\n\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: unawaited async inside viewModelScope.launch\n  !! uncaught on 'main': java.lang.IllegalStateException: analytics failed  (on Android: process dies)\n  launch cancelled=true, scope active=true\n2: unawaited async launched directly on the scope (root coroutine)\n  scope active=true (nothing printed above = silently stored)<\/pre>\n\n\n<p>Case 1 is a crash. The profile never prints, because the failing child cancelled the <code>launch<\/code>. Case 2 is the bug in this post. The failure is stored in a <code>Deferred<\/code> and nothing ever reports it.<\/p>\n\n<h2 class=\"wp-block-heading\">The fix<\/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 loadProfile(userId: String) {\n    viewModelScope.launch {\n        _uiState.update { it.copy(profile = api.getProfile(userId)) }\n    }\n    viewModelScope.launch {\n        try {\n            analytics.logProfileView(userId)\n        } catch (e: IOException) {\n            Log.w(\"Analytics\", \"profile view event failed\", e)\n        }\n    }\n}<\/pre>\n\n\n<p>Fire-and-forget work goes in <code>launch<\/code>, with its failure handled inside. Catch the specific exception you expect, not <code>Exception<\/code>. A broad catch also swallows the <code>CancellationException<\/code> thrown when the ViewModel is cleared.<\/p>\n\n<h2 class=\"wp-block-heading\">The rule to remember<\/h2>\n\n<p><strong>Use <code>async<\/code> only for results you will <code>await()<\/code>. Use <code>launch<\/code> for fire-and-forget work, and handle its failure inside.<\/strong><\/p>\n\n<ul>\n<li>An unawaited root <code>async<\/code> hides its failure.<\/li>\n<li>An unawaited child <code>async<\/code> still cancels its parent, and can crash the app under <code>viewModelScope<\/code>.<\/li>\n<li>A <code>SupervisorJob<\/code> stops a failure from cancelling siblings. It doesn&#8217;t handle the exception.<\/li>\n<\/ul>\n\n<h2 class=\"wp-block-heading\">How to answer this in an interview<\/h2>\n\n<ol>\n<li>Say that <code>viewModelScope.async<\/code> creates a root coroutine, and a root <code>async<\/code> stores its exception in the <code>Deferred<\/code>.<\/li>\n<li>Say that nobody awaits it, so nothing sees the failure. Add that <code>viewModelScope<\/code>&#8216;s <code>SupervisorJob<\/code> means nothing else gets cancelled either.<\/li>\n<li>Pre-empt the follow-up. Inside a <code>launch<\/code>, the same <code>async<\/code> is a child. Its failure cancels the parent and can crash the app.<\/li>\n<li>Give the fix as a rule. Use <code>async<\/code> only when you&#8217;ll await it. Use <code>launch<\/code> with explicit handling otherwise.<\/li>\n<\/ol>\n\n<p><strong>Common wrong answers:<\/strong><\/p>\n<ul>\n<li>&#8220;<code>async<\/code> always swallows exceptions.&#8221; Only as a root coroutine. As a child it propagates to the parent.<\/li>\n<li>&#8220;Add a <code>CoroutineExceptionHandler<\/code> to <code>viewModelScope<\/code>.&#8221; The docs say it has no effect on <code>async<\/code>.<\/li>\n<li>&#8220;Wrap <code>await()<\/code> in <code>try<\/code>\/<code>catch<\/code>.&#8221; That only works if something calls <code>await()<\/code>. The bug is that nothing does.<\/li>\n<\/ul>\n\n<hr\/>\n\n<p><em>Related: <a href=\"https:\/\/grindloop.ai\/blog\/why-your-combined-stateflow-shows-stale-data-sharingstarted-lazily-vs-eagerly\/\">why a combined StateFlow can show stale data<\/a>. GrindLoop&#8217;s Coroutines and Networking tracks turn failure patterns 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>A viewModelScope async call that nobody awaits hides its failure completely. Why a root async stores the exception, why the same code inside launch crashes instead, a runnable repro and the fix.<\/p>\n","protected":false},"author":2,"featured_media":209,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Why viewModelScope.async Swallows a Failed API Call","rank_math_description":"A viewModelScope async call nobody awaits hides its failure. Why a root async stores the exception, why the same code inside launch crashes, a repro and the fix.","rank_math_focus_keyword":"viewModelScope async","footnotes":""},"categories":[9,6],"tags":[15,13,16,12,57,45],"class_list":["post-155","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-bug-squash","category-networking","tag-android","tag-coroutines","tag-interview-prep","tag-kotlin","tag-networking","tag-technical-interview"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/155","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=155"}],"version-history":[{"count":20,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/155\/revisions"}],"predecessor-version":[{"id":688,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/155\/revisions\/688"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/209"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=155"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=155"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=155"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}