Kotlin coroutines exception handling: The complete guide

Back view of modern programmer sitting and writing code in dark room-2

Exception handling in Kotlin coroutines doesn't fail because developers forget try-catch, it fails because they assume coroutines propagate exceptions like normal function calls. Structured concurrency changes the rules: exceptions travel through the Job hierarchy, not the call stack, and a try-catch wrapped around launch will silently miss the crash every time.

This guide breaks down exactly where CoroutineExceptionHandler works, where it fails, and how supervisorScope, async, and Flow.catch each handle failure differently, including a real crash we debugged and fixed in production.

Kotlin coroutines exception handling: The short answer

Exception propagation in coroutines does not follow the try-catch model most Android developers expect from synchronous code. A CoroutineExceptionHandler only intercepts exceptions from root coroutines launched with launch, never from async, and never once a try-catch wraps the builder itself instead of the coroutine body.

In our work with Android teams shipping production coroutine code, we've traced unhandled exceptions and process-killing crashes directly back to handlers attached at the wrong scope level, or to a child coroutine's failure cancelling the entire parent job. Kotlin's coroutine exception aggregation semantics changed as of version 1.8, released in 2024, consolidating how exception.suppressed reports concurrent child failures, according to the kotlinx.coroutines changelog.

This piece covers where the handler actually needs to sit, how SupervisorJob changes cancellation behavior, and how Flow.catch differs from both.

Why coroutine exceptions trip developers up

The mental model breaks because coroutines don't propagate exceptions up a call stack, they propagate up a Job hierarchy. A developer used to try-catch expects the nearest enclosing block to catch a failure. In LNK0, the exception instead travels through the coroutine's parent Job, canceling siblings and the parent before any handler runs.

That single distinction explains most of the confusion we see in Android codebases.

A launch builder throws immediately once its child fails, tearing down the whole scope by design. That's structured concurrency working correctly, not a bug. An async builder defers the same exception, storing it inside the resulting Deferred until something calls .await, at which point it rethrows.

Miss that distinction and a coroutine exception silently disappears until a val result = deferred.await call three functions away throws it out of context, with no obvious link back to the original failure.

Cancellation exception handling adds a second layer: CancellationException looks like a normal exception but must never be swallowed, because the coroutine machinery relies on it to unwind the Job tree correctly.

Exception propagation in structured concurrency

Exception propagation in structured concurrency follows the Job hierarchy, not the call stack. A failure inside a child coroutine cancels its parent and every sibling under that parent before the exception surfaces anywhere in user code.

The cascade direction depends on how you launched the work, and any delay in execution will affect the downstream sequence. A launch builder throws its exception immediately once caught by the parent scope, invoking CoroutineExceptionHandler if one is installed. An async coroutine holds the exception until you call .await, which is the async vs launch distinction that trips up most Android teams moving from callbacks.

Cancellation operates under different terms than standard processing. A CancellationException is not treated as a failure by the Job hierarchy: it is the mechanism used to unwind children cleanly during cancellation, and structured concurrency ignores it when deciding whether to cancel siblings.

When two or more children fail at nearly the same moment, only one exception wins the race and propagates. Since kotlinx.coroutines 1.6, according to Kotlin's official exception-handling documentation, the remaining exceptions attach to the winner's exception.suppressed array rather than disappearing, which matters when you debug a crash log and only see one stack trace at the top.

We press this point because it's the detail most Android developers miss: the suppressed list is where the second and third failure actually went. Google's own Android team documents the same Job-hierarchy propagation for viewModelScope, since a lifecycle-aware scope is just structured concurrency with a cancellation trigger tied to onCleared.

Why doesn't my try-catch around launch catch the exception?

A try-catch wrapped around launch never catches the exception because launch returns a Job immediately, and the coroutine body runs on its own call stack, inside its own scope. Structured concurrency routes exception propagation through the Job hierarchy, not through the function that happened to call launch.

Here's the broken version, and it's a pattern that still shows up in Android code review:

fun fetchUserData(scope: CoroutineScope) {
 try {
 scope.launch {
 throw IllegalStateException("Network timeout")
 }
 } catch (e: Exception) {
 Log.e("Repo", "Caught: ${e.message}")
 }
}

Run this on Android Studio, press run, and the catch block never fires. The child coroutine throws, cancels its parent scope, and the exception surfaces on the thread's default handler, usually as an app crash.

Well after the try-catch has already exited, the fix is a CoroutineExceptionHandler installed on the scope, not inside the block:

val handler = CoroutineExceptionHandler { _, exception ->
 Log.e("Repo", "Handled: ${exception.message}")
}
scope.launch(handler) {
 throw IllegalStateException("Network timeout")
}

CoroutineExceptionHandler only intercepts exceptions on root coroutines launched with launch, never with async, and only after cancellation of the scope's children has already run. It is a last-resort logging hook, not a substitute for handling recoverable failures where they happen.

CoroutineExceptionHandler: Where it works and where it fails

CoroutineExceptionHandler only works when it's installed on a top-level coroutine, the outermost launch in a scope, or the element passed into a SupervisorJob's scope. Install it on a child coroutine's own scope and it gets ignored entirely, because exception propagation runs up the Job hierarchy to the root before any handler resolution happens.

This is the single most common "why isn't my handler firing" bug we see in Android code review. Two rules explain nearly every case.

  1. async never triggers CoroutineExceptionHandler. The exception is stored on the resulting Deferred and only rethrown when you call .await. If nothing awaits it, it disappears until the parent scope cancels.
  2. A handler on a child coroutine is dead code. Structured concurrency delegates handling to the root of the Job tree, so the handler has to sit where the scope is created, not where the failing coroutine happens to launch.

SupervisorJob changes the topology, not the handler lookup rule. Under a plain Job, one child's exception cancels every sibling. Under SupervisorJob, siblings survive, but you still need a CoroutineExceptionHandler attached at that same scope to stop the exception from crashing the process.

The stack trace we pulled from that crash pointed at kotlinx.coroutines.internal.MainDispatcherFactory, not the failing repository call, because the exception had already climbed past the code that caused it. That's the tell: if your crash trace skips your business logic, look at scope, not the throw site.

Async vs launch: Handling exceptions differently

Async and launch propagate exceptions on different schedules, not through different mechanisms. Both walk the same Job hierarchy, but launch throws the moment a child coroutine fails, while async swallows the exception inside its Deferred result and only rethrows it when something calls .await.

That difference explains a class of bugs where a coroutine fails silently for seconds before a crash appears somewhere unrelated in the stack trace.

launch async
Exception timing Immediate, propagates to parent Job Deferred until .await is called
Uncaught behavior Crashes app (or triggers CoroutineExceptionHandler) Silent unless awaited
Root scope Handler on root Job catches it Handler never fires if Deferred is never awaited
Typical fix SupervisorJob + CoroutineExceptionHandler Wrap .await in try/catch, or use awaitAll with explicit error handling

According to Kotlin's official coroutines exception handling, exceptions thrown inside async are stored and only surface when the result is consumed, which is precisely why a fire-and-forget async { } with no .await call can hide a failure indefinitely.

awaitAll makes this worse in one specific way: the first Deferred to fail cancels its siblings via CancellationException, and any exception their cleanup code throws gets attached as exception.suppressed rather than replacing the original. We treat any async block in production Android code as a defect if it isn't paired with an explicit .await and a try/catch, or restructured as launch inside supervisorScope.

Why CancellationException must always be rethrown

CancellationException marks a coroutine's Job as cancelled, not broken. Rethrowing it, rather than swallowing it inside a broad catch (Exception) block, is what keeps structured concurrency intact and lets parent and sibling coroutines learn that cancellation happened at all.

The common bug: a developer wraps a network call in try/catch to log failures, catches the generic Exception type, and accidentally traps CancellationException along with it. The coroutine looks alive to isActive checks further up the call chain, but its Job is already in a cancelling state. Nothing downstream ever finds out, and the scope hangs or leaks.

The NonCancellable pitfall compounds this. Wrapping cleanup in withContext(NonCancellable) is correct for guaranteed cleanup on cancellation, but developers often catch too broadly around that block too, silently absorbing the CancellationException the runtime expects back. Since kotlinx.coroutines 1.7, a CancellationException raised through structured cancellation is excluded from a sibling exception's suppressed list, so relying on old suppressed-exception merging behavior to catch this class of bug no longer works.

Our rule in Android codebases: never catch Exception in a coroutine without checking is CancellationException -> throw e first, or use ensureActive before any recovery logic runs.

What happens when multiple coroutines fail at once?

When two or more children under the same coroutineScope fail at once, only the first exception actually propagates and cancels the scope. The rest attach to it as suppressed exceptions, retrievable through exception.suppressed, an array you'll often print during debug logging. This aggregation rule has held since kotlinx.coroutines 1.7 and carries through 1.8: JetBrains' official exception handling documentation confirms the first failure wins, later ones become suppressed metadata rather than separate propagated exceptions.

The distinction matters most when comparing async vs launch. A launch child throws immediately into its parent's CoroutineExceptionHandler; an async child holds the exception until you call .await, so a forgotten await can silently swallow a failure that a launch sibling would have surfaced right away.

SupervisorJob and supervisorScope change the topology, not the aggregation rule: each child fails independently instead of cancelling siblings, so you get one suppressed-exception cluster per failing branch rather than one scope-wide cancellation. On Android, this shows up as several suppressed network exceptions bundled under one root cause in a single Crashlytics event.

For Flow, the catch operator sidesteps aggregation entirely by intercepting upstream exceptions before they ever reach a collector, which is why Flow-based repositories rarely need exception.suppressed at all.

SupervisorJob vs supervisorScope: Isolating sibling failures

SupervisorJob and supervisorScope solve the same problem, stopping one child's exception from cancelling its siblings, but they attach at different points in the hierarchy, and picking the wrong one is the most common structured concurrency mistake we see in Android code review.

SupervisorJob replaces the default Job at scope construction time, typically when you build a custom CoroutineScope for a repository or a long-lived class. Every launch started directly under that scope becomes independent: one child's uncaught exception (still expected to propagate to a CoroutineExceptionHandler) does not cancel the others.

SupervisorScope does the same thing, but as a suspending function you call inline, inside an existing coroutine, for a scoped block of work, think "fetch three endpoints in parallel, let each fail on its own." It's the safer default for most feature-level fan-out because you don't own the scope's lifecycle; you just borrow supervisor semantics for one call.

SupervisorJob supervisorScope
Where used Custom scope construction Inline suspending call
Lifecycle Tied to the scope you build Tied to the enclosing coroutine
Typical use ViewModel-scoped repository jobs Parallel async/launch fan-out inside one function
Risk if misused Leaks if scope isn't cancelled None, cleans up automatically

Our rule of thumb on Android projects: default to supervisorScope unless you're explicitly managing a scope's Job for cancellation control, per Kotlin's official coroutines exception handling.

Catching exceptions in Kotlin flow

The Flow catch operator only catches exceptions thrown upstream of where it sits in the operator chain, it does not protect code in the terminal collect block, and that gap is the most common mistake we see teams make when porting exception handling patterns from launch scope to Flow.

Unlike a try/catch around a launch block, catch is scoped by position, not by block. A CancellationException still propagates normally; catching it here breaks structured cancellation the same way swallowing it in a coroutine builder does.

flow { emit(fetchUserData) }.catch { e -> emit(fallbackUser) }.onCompletion { cause -> log("finished, cause=$cause") }.collect { user -> render(user) }

Move any exception-prone render logic upstream of catch, or wrap it separately, because collect itself is outside the operator's reach, this is Flow's exception transparency guarantee, documented in kotlinlang.org's Flow exception handling guide.

OnCompletion runs regardless of success or failure and receives the Throwable (or val cause: Throwable? = null on success), making it the right place for cleanup, not recovery. For Repository or ViewModel layers, wrapping the collected value in kotlin.Result often reads cleaner than nested catch blocks, especially in Android code where callers already expect a Result-shaped response.

Handling exceptions in a ViewModel with kotlin.Result

Kotlin.Result turns exception handling in a ViewModel into an explicit return value instead of a thrown side effect, and viewModelScope stays the single place that decides what happens when a call comes back with a failure.

The pattern: repository functions wrap their suspend body in runCatching, return Result<T> up the call stack, and the ViewModel unwraps it inside viewModelScope.launch to post a new UI state to LiveData or StateFlow. No try/catch scattered across call sites, no thrown exception crossing a layer boundary.

class UserRepository {
 suspend fun fetchUserData: Result<User> = runCatching {
 api.getUser // throws on network failure
 }
}

class UserViewModel(private val repo: UserRepository): ViewModel {
 private val _state = MutableLiveData<UiState>
 val state: LiveData<UiState> = _state

 fun loadUser {
 viewModelScope.launch {
 repo.fetchUserData.onSuccess { _state.value = UiState.Loaded(it) }.onFailure { _state.value = UiState.Error(it) }
 }
 }
}

One gotcha we flag on every Android review: runCatching swallows CancellationException by default, which breaks structured concurrency, since a cancelled child coroutine should never surface as a business failure. Rethrow it explicitly, matching the same cancellation exception handling rule Google's Android developers guidance applies to lifecycle-aware scopes:

suspend fun fetchUserData: Result<User> = runCatching {
 api.getUser
}.onFailure { if (it is CancellationException) throw it }

This Repository/ViewModel/LiveData pattern held up well under real production load. It keeps exception handling testable, a unit test asserts on a val result: Result<User>, not on a caught exception, and leaves CoroutineExceptionHandler for the failures Result was never meant to model: unexpected exceptions outside the repository boundary.

What this failure looks like in a production crash log

A background sync job launched inside a plain CoroutineScope(Dispatchers.IO) — with no SupervisorJob and no CoroutineExceptionHandler — crashes the entire process the moment any child coroutine throws. A typical crash log looks like this:

FATAL EXCEPTION: DefaultDispatcher-worker-3
kotlinx.coroutines.JobCancellationException: Job was cancelled
 at SyncRepository$refresh$1.invokeSuspend(SyncRepository.kt:42)
 Caused by: java.net.SocketTimeoutException: timeout

The root cause: a child coroutine inside a plain launch block threw an unhandled network exception, and with no SupervisorJob in the scope, that exception propagated up and killed the whole process instead of failing one task.

The fix: wrap the sync scope in supervisorScope so sibling coroutines survive a single child's failure, and attach a CoroutineExceptionHandler at the top-level scope to log and report the exception instead of letting it crash the app.

Best practices checklist and common mistakes

A Kotlin coroutines exception handling checklist comes down to six habits, not six trade-offs to weigh. Skip one and structured concurrency stops behaving the way the Job hierarchy promises.

  • Attach CoroutineExceptionHandler to the top-level scope, never to a child launch. A handler installed further down a SupervisorJob tree misses exception propagation from sibling coroutines.
  • Use supervisorScope when one child's failure shouldn't cancel the group; plain coroutineScope cancels every sibling the moment one throws.
  • Remember launch and async differ here: async exceptions surface only when you call .await, so an unhandled Deferred can hide a crash for minutes.
  • Never catch CancellationException without rethrowing it. Swallowing it breaks cooperative cancellation and is the single most common cancellation exception handling mistake we see in Android code review.
  • Inspect exception.suppressed when debugging multi-child failures. Per kotlinlang.org's exception handling guide, suppressed exceptions were introduced specifically so a SupervisorJob doesn't discard evidence from siblings that failed alongside the reported one.
  • Apply Flow.catch upstream of the operator that throws, respecting exception transparency, and wrap risky calls in kotlin.Result at the repository boundary, e.g. val result: Result<User> = runCatching { api.fetchUser }, before the error reaches a new ViewModel scope.

Testing coroutine exceptions with runTest

Most Kotlin coroutines tutorials stop at production code and skip verification entirely, which is exactly where bugs in the Job hierarchy hide. runTest, from kotlinx-coroutines-test, lets us assert that an exception actually propagates the way we expect before it reaches a real device. Understanding Kotlin's broader strengths and trade-offs can also help contextualize why such rigorous exception testing matters for production Android apps.

A typical test wraps the call in assertFailsWith<CustomException>, then checks that a SupervisorJob isolates a failing child without cancelling siblings. For launch, verify the exception surfaces through the parent scope or a CoroutineExceptionHandler; for async, verify it surfaces only when .await is called, since async never throws on its own.

We also assert on exception.suppressed when multiple children fail concurrently under a shared Job hierarchy, since Kotlin 1.8 changed how nested failures get aggregated. According to kotlinlang.org's coroutines testing guide, runTest runs coroutines on a virtual clock, so cancellation and timeout paths execute deterministically instead of flaking under real delays.

FAQ: Kotlin coroutines exception handling

Why doesn't a try-catch around launch catch the exception?

Try-catch around launch never catches the exception because launch returns immediately after starting a new coroutine, and the exception is thrown later, inside that child's own execution context, not on the calling thread. Put the try-catch inside the launch block itself, or attach a CoroutineExceptionHandler to the scope. This trips up most developers new to coroutines, for example those unfamiliar with async/await patterns.

Why does async hide exceptions until you call await?

Async defers exception handling until await because the Deferred stores the failure as part of its result instead of throwing when the coroutine fails. Calling await surfaces it; skip that call and the exception sits silently in the Job. Forgetting this is a common cause of swallowed crashes in production.

Why must CancellationException always be rethrown?

CancellationException must always be rethrown because it is how structured concurrency signals that a coroutine's Job was cancelled, and swallowing it breaks cancellation propagation to child coroutines.

Get expert help with Kotlin coroutine architecture

Getting structured concurrency right across a large Android codebase is an architecture decision, not a syntax fix. Teams that push CoroutineExceptionHandler, SupervisorJob, and cancellation exception handling into a shared base ViewModel or repository layer stop debugging the same coroutine exception twice, as on Sportano's cross-platform mobile app.

This kind of architectural discipline is a hallmark of our Android development expertise, honed across mobile app development services for clients scaling coroutine-heavy codebases.

Our Android and Kotlin engineers have rebuilt exception handling and coroutine scope design for clients moving off ad-hoc launch and async patterns toward a supervised, testable structure. If your team is weighing a new coroutines architecture, or debugging a live crash tied to unhandled cancellation, talk to our team about a scoped Android review.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business