Why viewModelScope.async Swallows a Failed API Call

A ProfileViewModel loads a profile and logs an analytics event when the screen opens. The analytics call starts failing on a flaky network path. There’s no crash, no log line and no Crashlytics entry. The dashboard just shows the event never fires. What’s wrong?

class ProfileViewModel(
    private val api: ProfileApi,
    private val analytics: AnalyticsApi
) : ViewModel() {

    private val _uiState = MutableStateFlow(ProfileUiState())
    val uiState: StateFlow<ProfileUiState> = _uiState

    fun loadProfile(userId: String) {
        viewModelScope.launch {
            _uiState.update { it.copy(profile = api.getProfile(userId)) }
        }
        viewModelScope.async { analytics.logProfileView(userId) } // fire-and-forget
    }
}

This viewModelScope async call is the problem. The analytics work runs in viewModelScope.async, and nobody calls await() on the result. An async started directly on a scope is a root coroutine. Kotlin’s rule for root coroutines is that async doesn’t report its exception. It stores it in the returned Deferred and waits for someone to call await(). Here the Deferred is thrown away, so the failure is never seen. A CoroutineExceptionHandler wouldn’t help either, since the docs say it has no effect on async. And viewModelScope runs on a SupervisorJob, so the failure doesn’t cancel anything else. The fix is to use launch for fire-and-forget work and handle the failure inside it. Keep async for work you will await(). One detail is easy to miss. Put the same async inside a launch and the behavior flips. It’s a child then, so its failure propagates and can crash the app.

Why a viewModelScope async call hides the failure

Kotlin’s coroutine exception-handling guide describes two kinds of builders. launch propagates exceptions automatically. async and produce expose them to the caller, who is expected to consume them, for example with await(). The guide applies this distinction to root coroutines, meaning ones that aren’t children of another coroutine. It also says a CoroutineExceptionHandler has no effect on async. The builder catches every exception and puts it in the Deferred.

viewModelScope.async { ... } creates exactly that kind of root coroutine. Its exception sits in a Deferred that no code keeps. Nothing logs it, rethrows it or reports it.

Why nothing else notices

viewModelScope is built on a SupervisorJob. Here’s 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())
}

The same Kotlin guide says a child’s failure doesn’t propagate to a supervisor job or its other children. So the failed async doesn’t cancel the profile load or the scope. With no await, no handler and no cancellation, the failure leaves no trace.

Inside a launch, the same async crashes instead

It’s a natural follow-up question. Move the fire-and-forget async inside a launch, and it’s no longer a root coroutine. It’s a child of that launch, which runs on a regular Job. A failing child cancels its parent, whether or not anyone awaits it. The launch then fails. Its exception reaches the scope’s SupervisorJob. With no handler installed, it goes to the thread’s uncaught exception handler. On Android, that crashes the app.

This small JVM program rebuilds viewModelScope the same way, with a SupervisorJob and a single thread named “main.” It installs an uncaught handler that prints instead of exiting. It runs on kotlinx.coroutines 1.9.0.

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

suspend fun getProfile(): String { delay(200); return "profile" }
suspend fun logProfileView() { delay(50); throw IllegalStateException("analytics failed") }

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

    println("1: unawaited async inside viewModelScope.launch")
    val s1 = fakeViewModelScope()
    val job1 = s1.launch {
        val profile = async { getProfile() }
        async { logProfileView() } // fire-and-forget
        println("  profile = ${profile.await()}")
    }
    job1.join()
    println("  launch cancelled=${job1.isCancelled}, scope active=${s1.isActive}")

    println("2: unawaited async launched directly on the scope (root coroutine)")
    val s2 = fakeViewModelScope()
    s2.async { logProfileView() }
    delay(300)
    println("  scope active=${s2.isActive} (nothing printed above = silently stored)")

    mainThread.close()
}
1: unawaited async inside viewModelScope.launch
  !! uncaught on 'main': java.lang.IllegalStateException: analytics failed  (on Android: process dies)
  launch cancelled=true, scope active=true
2: unawaited async launched directly on the scope (root coroutine)
  scope active=true (nothing printed above = silently stored)

Case 1 is a crash. The profile never prints, because the failing child cancelled the launch. Case 2 is the bug in this post. The failure is stored in a Deferred and nothing ever reports it.

The fix

fun loadProfile(userId: String) {
    viewModelScope.launch {
        _uiState.update { it.copy(profile = api.getProfile(userId)) }
    }
    viewModelScope.launch {
        try {
            analytics.logProfileView(userId)
        } catch (e: IOException) {
            Log.w("Analytics", "profile view event failed", e)
        }
    }
}

Fire-and-forget work goes in launch, with its failure handled inside. Catch the specific exception you expect, not Exception. A broad catch also swallows the CancellationException thrown when the ViewModel is cleared.

The rule to remember

Use async only for results you will await(). Use launch for fire-and-forget work, and handle its failure inside.

  • An unawaited root async hides its failure.
  • An unawaited child async still cancels its parent, and can crash the app under viewModelScope.
  • A SupervisorJob stops a failure from cancelling siblings. It doesn’t handle the exception.

How to answer this in an interview

  1. Say that viewModelScope.async creates a root coroutine, and a root async stores its exception in the Deferred.
  2. Say that nobody awaits it, so nothing sees the failure. Add that viewModelScope‘s SupervisorJob means nothing else gets cancelled either.
  3. Pre-empt the follow-up. Inside a launch, the same async is a child. Its failure cancels the parent and can crash the app.
  4. Give the fix as a rule. Use async only when you’ll await it. Use launch with explicit handling otherwise.

Common wrong answers:

  • “async always swallows exceptions.” Only as a root coroutine. As a child it propagates to the parent.
  • “Add a CoroutineExceptionHandler to viewModelScope.” The docs say it has no effect on async.
  • “Wrap await() in try/catch.” That only works if something calls await(). The bug is that nothing does.

Related: why a combined StateFlow can show stale data. GrindLoop’s Coroutines and Networking tracks turn failure patterns like this one into live debugging drills. Each drill comes with a reviewed fix.

Failed the interview? Not the next one.

1 thought on “Why viewModelScope.async Swallows a Failed API Call”

Leave a Comment