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
supervisorScopeto isolate child jobs. - Keep Flow exception boundaries clear.
- Don’t rely on
CoroutineExceptionHandlerfor business logic. - Test your failure and cancellation paths.

