Coroutine Exceptions & Error Handling Explained
KOTLIN › Coroutines
The single most useful sentence about coroutine failure is this: **an exception in a coroutine is a failure of its Job, not an exception at the call site that started it.**
Everything confusing about this topic comes from expecting the second thing and getting the first.
try {
scope.launch { throw RuntimeException("boom") }
} catch (e: Exception) {
// Never reached. Not once.
}
launch returns as soon as the coroutine is scheduled, long before the body runs. By the time throw executes, the try block has already exited. The exception has nowhere to go at the call site, so it takes the other route: it fails the coroutine's Job, which reports the failure to its parent Job, which by default cancels its other children and fails in turn, all the way to the root of the scope.
That is why coroutine error handling has its own vocabulary. try/catch still works, but only **inside** the coroutine, around the suspending call that might throw. Anything about what happens when nobody caught it, which is most of this topic, is about the job hierarchy: who gets told, who gets cancelled, and who is allowed to handle it.
Hold on to the distinction and the rules stop looking arbitrary.
If try/catch at the call site is wrong, the obvious fix is to put it inside the coroutine. That is correct, and it is the right tool for **expected, specific** failures:
viewModelScope.launch {
val user = try {
api.fetchUser(id) // may throw IOException
} catch (e: IOException) {
_state.value = UiState.Offline
return@launch
}
_state.value = UiState.Loaded(user)
}
Two rules make this reliable.
**Catch narrowly.** catch (e: Exception) also catches CancellationException, which is how cancellation is delivered. Swallow it and you have written a coroutine that ignores cancel(), keeps running after its ViewModel is cleared, and updates state on a dead screen. Catch IOException, or catch broadly and rethrow:
catch (e: CancellationException) { throw e }
catch (e: Exception) { /* real failure */ }
**Wrap the call, not the builder.** The try has to be inside the lambda, around the suspending call. Wrapping launch itself catches nothing, as the previous chunk showed.
One subtlety that surprises people: a try/catch inside a child coroutine does **not** stop the parent scope from being cancelled if a *different* child fails. Catching handles your own failure; it has no say over a sibling's.
launch and async handle failure through the exact same mechanism, both create a child Job, and by default an uncaught exception in either one is reported straight up through the job hierarchy to the parent. That's what cancels the parent scope and its other children the moment one fails, and it happens whether the failing coroutine was started with launch or async.
What async does *in addition* is store that exception in its Deferred, so if you call await() you'll also get it rethrown there. That's a second surfacing path, not a replacement for the immediate propagation:
coroutineScope {
val d = async { repo.load() } // load() throws
// no d.await() called anywhere...
doOtherWork() // never completes, the scope was already cancelled
}
// exception rethrown here, from the scope itself
The idea that 'the exception just waits for await()' only fully applies to a **root** async, one with no structured parent actively watching it, like GlobalScope.async { ... }. There, nothing happens until something calls await(); if nothing ever does, the exception is silently lost. Inside a real coroutineScope or supervisorScope, the scope is always watching its direct children, so a failure, from launch or async, propagates the instant it happens, await() or not.
So try/catch handles specific, expected failures around a particular suspending call, but what about a genuinely uncaught exception, one nobody wrapped in a try? That's what CoroutineExceptionHandler is for, and its rules are narrower than people expect. It only fires for an uncaught exception from a launch that is a **root** coroutine of its scope, for example installed directly on the scope's context, or on the top-level coroutine inside supervisorScope.
Two things trip people up:
- It's ignored on async. An async coroutine's exception is stored in its Deferred and only surfaces when you call await(), it never reaches the handler, no matter how it's installed. - It's never invoked for CancellationException. That's not a failure, it's normal cancellation working as intended.
val handler = CoroutineExceptionHandler { _, e -> log("caught: $e") }
launch(handler) { throw RuntimeException("oops") } // handler fires
val d = async(handler) { throw RuntimeException("oops") }
d.await() // exception rethrown here instead, handler never called
The rule that catches almost everyone is **where** a CoroutineExceptionHandler has to be installed. It handles failures for the coroutine whose context contains it *as a root of its scope*, which means installing it on the child usually does nothing.
val handler = CoroutineExceptionHandler { _, e -> log("caught: $e") }
// Does NOT work: the launch is a child, its failure propagates to the parent
scope.launch {
launch(handler) { throw RuntimeException("boom") }
}
// Works: the handler is on the root coroutine of the scope
scope.launch(handler) {
launch { throw RuntimeException("boom") }
}
In the first case the inner coroutine is not a root. Its failure is reported to its parent rather than handled locally, so the handler in its own context is simply ignored and the app crashes anyway.
The most robust placement is on the scope itself, so every coroutine started from it is covered:
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + handler)
Note the SupervisorJob there. With a regular Job, the first failure cancels the scope permanently and every later launch on it is dead on arrival. A long-lived scope that must survive individual failures needs both: a supervisor to stop failures cascading, and a handler to deal with what does get through.
supervisorScope and SupervisorJob change which failures propagate, and combining them with a handler is where the two ideas meet.
Under a plain Job, one child's failure cancels its siblings and the parent. Under a supervisor, each **direct** child fails alone:
supervisorScope {
launch(handler) { throw RuntimeException("A failed") } // handled, isolated
launch { delay(500); println("B still runs") } // unaffected
}
Notice the handler is on the child here, and this time it fires. That is not a contradiction of the previous rule: under supervisorScope, each direct child **is** a root as far as failure handling goes, because there is no parent willing to take the failure. That single fact reconciles the two examples people usually see as inconsistent.
The isolation is shallower than it looks. A supervisor protects its direct children only, not grandchildren:
supervisorScope {
launch { // direct child: isolated
launch { throw Exception() } // grandchild: NOT isolated
launch { neverRuns() } // cancelled by its sibling above
}
}
The grandchild's failure propagates to its own parent, which is a regular coroutine with a regular Job, so that inner group cancels as a unit. The supervisor only stops it spreading further sideways.
CancellationException is the one throwable the whole system treats as **not a failure**, and knowing where that exemption applies is a reliable senior signal.
It is never delivered to a CoroutineExceptionHandler, never cancels a parent, and never fails a sibling. When you call job.cancel(), the coroutine is resumed with a CancellationException so that its finally blocks run and it unwinds cleanly. That is normal shutdown, not an error, so treating it as one produces false crash reports and noisy logs.
try {
doWork()
} catch (e: CancellationException) {
throw e // let it keep unwinding
} catch (e: Exception) {
crashReporter.log(e) // genuine failures only
}
Two consequences worth stating in an interview:
- A parent cancelling its children is **not** a failure of the parent. It completes normally once they finish unwinding. - TimeoutCancellationException from withTimeout is a CancellationException subclass, so it obeys all of the above. That is why withTimeout cancels only its own block rather than failing the enclosing scope, and why catching it needs a specific clause rather than a broad one.
try { withTimeout(2000) { load() } }
catch (e: TimeoutCancellationException) { showRetry() } // fine: specific
Parallel decomposition with async has failure semantics of its own, and awaitAll is where they show up.
suspend fun loadScreen(): Screen = coroutineScope {
val user = async { api.user() }
val feed = async { api.feed() } // throws after 50ms
val ads = async { api.ads() } // still in flight
Screen(user.await(), feed.await(), ads.await())
}
When api.feed() throws, three things happen essentially at once. The failure propagates to the coroutineScope, which cancels the other two async coroutines, and loadScreen rethrows the original exception to its caller. You do not get partial results, and ads is cancelled mid-flight rather than being allowed to finish.
That is usually what you want: a screen that needs all three has nothing useful to show. When it is not what you want, say the ads are optional, the fix is to isolate that one call rather than to catch around the whole thing:
val ads = async { runCatching { api.ads() }.getOrNull() }
awaitAll(user, feed, ads) behaves the same way and is preferable when you have a list: it rethrows the **first** failure as soon as it occurs, rather than waiting for the remaining deferreds to finish first. Awaiting them one at a time in sequence would delay the error until you happened to reach the failing one.
When several children fail at once, only one exception can be thrown. The rest are not discarded: they are attached as **suppressed** exceptions.
try {
coroutineScope {
launch { throw IllegalStateException("first") }
launch { throw IOException("second") }
}
} catch (e: Exception) {
println(e) // java.lang.IllegalStateException: first
println(e.suppressed.toList()) // [java.io.IOException: second]
}
The first failure to reach the parent wins and becomes the thrown exception; later ones are recorded on it. Crash reporters and log lines that print only e.message therefore hide half the story, which is a good practical reason to log stackTraceToString() instead.
There is a related trap with cancellation ordering. If a child fails and a sibling then throws while unwinding, the sibling's exception is suppressed onto the original, not swapped for it. The exception you see is the **cause** of the cancellation, not the last thing that went wrong, and that is deliberate: the root cause is what you want at the top of a crash report.
// Logs the full picture, including suppressed causes
catch (e: Exception) { crashReporter.log(e.stackTraceToString()) }
Most real error handling is not "catch and log", it is "try again". Coroutines make retry loops ordinary code rather than callback gymnastics:
suspend fun <T> retry(
times: Int = 3,
initialDelay: Long = 200,
factor: Double = 2.0,
block: suspend () -> T,
): T {
var delayMs = initialDelay
repeat(times - 1) {
try {
return block()
} catch (e: CancellationException) {
throw e // never retry a cancellation
} catch (e: IOException) {
delay(delayMs) // suspends, does not block
delayMs = (delayMs * factor).toLong()
}
}
return block() // last attempt, let it throw
}
Three details make this correct rather than merely plausible. The CancellationException clause comes **first**, so cancelling the screen does not trigger three more attempts. The catch is narrowed to the failure worth retrying, since retrying a JsonParseException will fail identically every time. And the final attempt is outside the loop so the real exception escapes rather than being swallowed by the last iteration.
Backoff matters on mobile in particular: a flaky connection recovering will otherwise be hit by every client in your install base at the same instant. Adding jitter, a small random offset to each delay, spreads that out.
Layering decides *where* to catch, and it is the part interviewers use to separate people who have shipped an app from people who have read the docs.
A useful default: **repositories convert exceptions into values, ViewModels convert values into state.**
// Repository: the network's problems stop here
suspend fun user(id: String): Result<User> = runCatching { api.fetchUser(id) }
// ViewModel: turns that into something the UI can render
viewModelScope.launch {
_state.value = repo.user(id).fold(
onSuccess = { UiState.Loaded(it) },
onFailure = { UiState.Error(it.toMessage()) },
)
}
The benefit is that failure becomes part of the function's type. A caller cannot forget to handle it, and the ViewModel never needs a try/catch at all.
The catch, and it is a real one: runCatching catches Throwable, **including CancellationException**. Used naively inside a coroutine it breaks cancellation exactly as a broad catch does. Either rethrow explicitly, or use a small typed wrapper instead:
suspend fun <T> catching(block: suspend () -> T): Result<T> =
try { Result.success(block()) }
catch (e: CancellationException) { throw e }
catch (e: Exception) { Result.failure(e) }
A domain-specific sealed result (Success, NetworkError, NotFound) is often better still, since it forces the UI to think about each case rather than mapping everything to one error string.
Put the rules in one place and this stops being a memory test.
**Who sees the exception?**
| Started with | Uncaught failure goes to | |---|---| | launch (child) | parent Job, cancelling siblings | | launch (root of scope) | CoroutineExceptionHandler, else the thread's default handler | | async (child) | parent Job immediately, **and** rethrown at await() | | async (root, no parent watching) | stored only; lost if nothing awaits | | any, CancellationException | nobody: it is not a failure |
**Which tool for which job?**
- Expected failure at a known call: try/catch inside the coroutine, narrowed, cancellation rethrown. - Independent children that must not take each other down: supervisorScope or a scope built on SupervisorJob. - Last-resort logging for anything uncaught: CoroutineExceptionHandler on the **scope**, not on a nested child. - Failure as a normal outcome across a layer boundary: Result or a sealed type, with cancellation rethrown. - Deadline: withTimeout, remembering it throws a CancellationException subtype.
The answer that signals experience is knowing that a handler is a **crash-reporting** tool, not a control-flow one. If a failure has a meaningful response, it belongs in a try/catch or a Result where the code that knows what to do can see it. By the time it reaches the handler, all you can honestly do is log it.