Introduction
Unhandled exceptions in coroutines can silently crash your app or leave your users staring at a blank screen. Exception handling in coroutines: lessons from production reveals why the patterns that work in traditional threaded code often fail when you move to async. We've learned through deployment incidents that coroutines demand a different mental model—one where cancellation is not an exception, where scope matters, and where top-level handlers catch what local try/catch cannot.
Try/catch around suspend calls
The most intuitive approach is wrapping your suspend functions in try/catch, and it works—as long as you understand what it catches and what it doesn't.
suspend fun fetchUserData(userId: String): User {
return try {
api.getUser(userId)
} catch (e: IOException) {
logger.error("Network failure for user $userId", e)
User.empty()
}
}
This pattern is solid for local, synchronous-feeling failures. If the network request times out, a JSON parse fails, or a database query returns null, you catch it and handle it gracefully. The suspend call completes or throws; try/catch deals with both outcomes.
However, this breaks down when the coroutine scope itself is cancelled. If a parent job cancels before your suspend function returns, a CancellationException is thrown—and we'll cover why you must never swallow that.
In production e-commerce flows, we use try/catch around suspend calls to recover from transient failures without crashing the entire checkout flow:
suspend fun processPayment(orderId: String): PaymentResult {
return try {
paymentGateway.charge(orderId)
} catch (e: PaymentTimeoutException) {
logger.warn("Payment gateway timeout, retrying...", e)
paymentGateway.charge(orderId) // Retry once
} catch (e: Exception) {
PaymentResult.Failure("Payment service unavailable")
}
}
This works because the exceptions are real application errors, not cancellation signals. The scope is still active, and we're handling domain-level problems within the same async context.
CoroutineExceptionHandler for top-level coroutines
Try/catch handles exceptions within a single suspend call or a small chain. But what catches uncaught exceptions thrown by a coroutine that nobody is explicitly awaiting? That's where CoroutineExceptionHandler comes in.
A CoroutineExceptionHandler is a coroutine context element that intercepts uncaught exceptions in root-level coroutines (those launched without a parent job in a scope like GlobalScope or a custom CoroutineScope).
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
logger.error("Uncaught coroutine exception", exception)
crashReporter.report(exception)
}
viewModelScope.launch(exceptionHandler) {
val user = fetchUserData("123")
displayUser(user)
}
Without the handler, the exception propagates to the scope's default handler, which often means a crash or silent failure depending on the dispatcher and platform. In Android apps, unhandled exceptions on Dispatchers.Main can bring down the entire process.
In a ride-hailing backend we maintained, top-level request handlers used a CoroutineExceptionHandler to catch any unhandled exception from async database queries or third-party API calls, log them to a tracing service, and return a 500 response instead of letting the thread pool worker explode:
val requestHandler = CoroutineExceptionHandler { _, exception ->
metrics.recordException(exception)
sentryClient.captureException(exception)
}
suspend fun handleRideRequest(request: RideRequest): Response = withContext(
Dispatchers.IO + requestHandler
) {
val driver = matchDriver(request)
val route = computeRoute(driver, request)
Response.Success(route)
}
Key insight: CoroutineExceptionHandler only works on root coroutines. If your exception is thrown in a child coroutine and you're using supervisorScope or structured concurrency, the handler on the parent won't catch it. That's by design—it forces you to handle failures at the right level.
supervisorScope isolates child failures
When you launch multiple concurrent tasks and one fails, what happens to the others? In a regular coroutineScope, one uncaught exception cancels all siblings and propagates up. That's fine for "all-or-nothing" operations, but it's disastrous when you're fetching data from multiple independent sources.
// This will crash if ANY request fails
suspend fun loadDashboard(): Dashboard {
return coroutineScope {
val userJob = async { api.getUser() }
val postsJob = async { api.getPosts() }
val settingsJob = async { api.getSettings() }
Dashboard(
user = userJob.await(),
posts = postsJob.await(),
settings = settingsJob.await()
)
}
}
supervisorScope breaks this chain. Child coroutine failures don't cancel siblings, and exceptions are only thrown if you explicitly await a failed child.
// Each request fails independently
suspend fun loadDashboard(): Dashboard {
return supervisorScope {
val userDeferred = async {
try {
api.getUser()
} catch (e: Exception) {
logger.error("Failed to load user", e)
User.default()
}
}
val postsDeferred = async {
try {
api.getPosts()
} catch (e: Exception) {
logger.error("Failed to load posts", e)
emptyList()
}
}
Dashboard(
user = userDeferred.await(),
posts = postsDeferred.await()
)
}
}
In a travel booking platform, we used supervisorScope to fetch availability from three flight providers in parallel. If one API timed out, the user still saw results from the other two instead of a blank screen:
suspend fun searchFlights(query: FlightQuery): List {
return supervisorScope {
val provider1 = async { skyScannerClient.search(query) }
val provider2 = async { amadeuClient.search(query) }
val provider3 = async { kayakClient.search(query) }
val results = mutableListOf()
try { results.addAll(provider1.await()) } catch (e: Exception) { }
try { results.addAll(provider2.await()) } catch (e: Exception) { }
try { results.addAll(provider3.await()) } catch (e: Exception) { }
results
}
}
The pattern is: supervisorScope for concurrent tasks that can fail independently. Each await is wrapped in its own try/catch. One failure doesn't cascade.
Never swallow CancellationException
CancellationException is not a bug—it's a signal. When a coroutine's job is cancelled (timeout, parent scope destroyed, explicit cancel), a CancellationException is thrown at the suspension point. Swallowing it breaks cancellation semantics and leaves zombie coroutines running.
// WRONG - DO NOT DO THIS
launch {
try {
val data = suspendingOperation()
updateUI(data)
} catch (e: Exception) {
// This catches CancellationException too!
logger.error("Error", e)
}
}
If the parent scope is destroyed (user navigates away, activity destroyed), cancellation is thrown. Your catch swallows it, and the coroutine keeps running—accessing invalid UI references, making unnecessary network calls, leaking memory.
The fix is to never catch CancellationException or re-throw it immediately:
// CORRECT
launch {
try {
val data = suspendingOperation()
updateUI(data)
} catch (e: CancellationException) {
throw e // Always re-throw
} catch (e: Exception) {
logger.error("Error", e)
}
}
// OR use the extension (Kotlin 1.7+)
launch {
try {
val data = suspendingOperation()
updateUI(data)
} catch (e: Exception) {
logger.error("Error", e)
}
// CancellationException is not a subclass of Exception
}
In Kotlin 1.7 and later, CancellationException doesn't inherit from Exception, so a bare catch (e: Exception) won't catch it. But older codebases and some libraries still use the old hierarchy—be explicit.
In a SaaS product we support, forgetting to re-throw CancellationException led to user sessions leaking. A viewmodel's coroutine would keep trying to sync data even after the user logged out, causing unexpected API calls and memory bloat:
// Before (leaking)
class UserViewModel : ViewModel() {
fun syncData() = viewModelScope.launch {
try {
while (true) {
repository.sync()
delay(30_000)
}
} catch (e: Exception) {
logger.error("Sync failed", e)
}
}
}
// After (correct)
class UserViewModel : ViewModel() {
fun syncData() = viewModelScope.launch {
try {
while (true) {
repository.sync()
delay(30_000)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.error("Sync failed", e)
}
}
}
Common mistakes
- Catching Exception too broadly: A bare
catch (e: Exception)in older Kotlin might swallowCancellationException. Be specific; catch only what you expect to handle. - Using CoroutineExceptionHandler on child coroutines: It only works on root coroutines. For children, handle exceptions where you await or use
supervisorScope. - Not re-throwing CancellationException: Even if you log it or do cleanup, always re-throw. Otherwise, the coroutine's contract is broken.
- Mixing coroutineScope and supervisorScope carelessly: If one task failing should fail everything, use
coroutineScope. If tasks are independent, usesupervisorScope. Get it wrong, and you'll either crash too easily or swallow real failures. - Ignoring timeouts as exceptions:
withTimeoutOrNullreturns null, butwithTimeoutthrowsTimeoutCancellationException. Know which you're using and handle accordingly.
Key takeaways
- Wrap individual suspend calls in try/catch for application-level errors like network timeouts or parse failures.
- Use
CoroutineExceptionHandleronly on root-level coroutines to catch exceptions that bubble up uncaught. - Choose
supervisorScopewhen child coroutines can fail independently; usecoroutineScopefor all-or-nothing semantics. - Always re-throw
CancellationException. It's not an error—it's a cancellation signal. Swallowing it breaks structured concurrency and leaks resources. - Test failure paths explicitly: disable networks, throw exceptions from mocks, cancel scopes early. Exception handling is only as good as your tests.






Comments
Loading comments…