15 Common Kotlin Coroutine Exception Handling Mistakes (and How to Fix Them for Android Developers)

If you’ve ever used Kotlin Coroutines in Android and thought “why isn’t my try/catch working?” — you’re not alone.

Exception handling in coroutines can be surprisingly tricky. Many Android developers misuse launch, async, or CoroutineExceptionHandler, causing crashes, silent failures, or cancelled jobs that break their app in production.

In this post, we’ll cover the 15 most common coroutine exception handling mistakes, show real-world code samples, and explain how to fix them the right way — optimized for performance, stability, and readability.


1. Wrapping launch with try/catch (and expecting it to work)

//  This will NOT catch the exception inside launch
try {
    viewModelScope.launch {
        throw IllegalStateException("Boom")
    }
} catch (e: Exception) {
    // Never executed
}

Fix: Catch inside the coroutine

viewModelScope.launch {
    try {
        repository.loadData()
    } catch (e: Exception) {
        showError(e.message)
    }
}

SEO Tip: People search “Kotlin coroutine try catch not working” — this example solves that exact query.


2. Misusing CoroutineExceptionHandler

A common misconception is that CoroutineExceptionHandler catches every coroutine exception — it doesn’t.

It only handles uncaught exceptions in root launch coroutines.

val handler = CoroutineExceptionHandler { _, e -> log(e) }

viewModelScope.launch(handler) {
    val data = async { riskyNetworkCall() } //  not caught by handler
    data.await() // crash here
}

Fix

Handle exceptions at await() and withContext boundaries:

viewModelScope.launch(handler) {
    try {
        val result = async { riskyNetworkCall() }.await()
    } catch (e: Exception) {
        handleError(e)
    }
}

3. Using async for fire-and-forget work

async is for concurrent results, not side jobs.

//  async without await -> lost exception
viewModelScope.launch {
    async { syncData() }
}

Fix: Use launch for fire-and-forget

viewModelScope.launch {
    launch(Dispatchers.IO) { syncData() }
}

4. Swallowing CancellationException

You must rethrow CancellationException or your coroutine won’t cancel properly.

try {
    withContext(Dispatchers.IO) { fetch() }
} catch (e: Exception) {
    //  Also catches CancellationException
    showError("Error loading data")
}

Fix

catch (e: Exception) {
    if (e is CancellationException) throw e
    showError("Error loading data")
}

5. Forgetting supervisorScope / SupervisorJob

Without supervision, one child coroutine failure cancels all others.

viewModelScope.launch {
    val a = launch { repo.loadFeed() }
    val b = launch { repo.loadRecommendations() } // cancelled if a fails
}

Fix

viewModelScope.launch {
    supervisorScope {
        launch { safeLoadFeed() }
        launch { safeLoadRecommendations() }
    }
}

6. Thinking .catch {} catches everything in Flows

.catch {} only handles upstream errors, not map or collect exceptions.

//  Crashes if map/collect throws
repo.itemsFlow()
    .map { riskyMap(it) }
    .catch { emit(emptyList()) }
    .collect { render(it) }

Fix

repo.itemsFlow()
    .catch { e -> emit(emptyList()) }
    .onEach {
        try { render(transform(it)) } catch (e: Exception) { showError(e) }
    }
    .launchIn(viewModelScope)

7. Treating cancellations or timeouts as real errors

flow {
    withTimeout(1500) { emit(api.fetch()) }
}.catch { emit(State.Error("Timeout!")) } // Wrong

Fix

.catch { e ->
    if (e is CancellationException) throw e
    emit(State.Error("Server took too long"))
}

8. Using GlobalScope

GlobalScope breaks structured concurrency — jobs outlive screens and leak memory.

GlobalScope.launch {
    repo.sync() //  may still run after activity destroyed
}

Fix

Use viewModelScope or lifecycleScope.


9. Leaking infrastructure exceptions to the UI

Handle errors in your data layer, not in the UI.

// ❌ UI should not know about HttpException
try {
    val user = api.getUser()
} catch (e: HttpException) {
    showError("HTTP ${e.code()}")
}

Fix

Map to domain-level errors.


10. Expecting SupervisorJob to absorb all errors

Even with a SupervisorJob, uncaught exceptions still crash — handle each child properly.


11. Misusing withContext(NonCancellable)

Only use it for cleanup, never for long-running tasks.


12. Over-retrying failed calls

Use conditional retries:

.retry(3) { e -> e is IOException || e is HttpException && e.code() >= 500 }

13. Using runBlocking on main

Never block the main thread. Use background dispatcher or async initialization.


14. Forgetting to catch inside combine() or zip()

Apply .catch to each flow before combining.


15. Not testing error paths

Always test cancellation and failure using runTest in unit tests.


Real-World Best Practice Example

Here’s how a production-quality coroutine pattern should look:

viewModelScope.launch {
    supervisorScope {
        val feed = async { safeCall { repo.feed() } }
        val recs = async { safeCall { repo.recommendations() } }

        _uiState.value = State.Content(
            feed = feed.await(),
            recommendations = recs.await()
        )
    }
}

suspend fun <T> safeCall(block: suspend () -> T): T? =
    try { block() }
    catch (e: Exception) {
        if (e is CancellationException) throw e
        logError(e)
        null
    }

Final Thoughts

Mastering exception handling in Kotlin Coroutines is one of the most important skills for Android developers in 2025.
Handled wrong, a coroutine crash can silently break your app. Handled right, your app becomes resilient, responsive, and production-safe.

Key Takeaways:

  • Always catch inside your coroutine.
  • Never swallow CancellationException.
  • Use supervisorScope to isolate child jobs.
  • Keep Flow exception boundaries clear.
  • Don’t rely on CoroutineExceptionHandler for business logic.
  • Test your failure and cancellation paths.

computertable

Electric Automatic Height Adjustable Sit Stand Desk

Leave a Comment

Your email address will not be published. Required fields are marked *