1. Introduction
If you’re just getting started, it’s easy to think: “Exceptions are try/catch; I’ve already been through that. So in a coroutine it’s all the same.” And that’s partly true: try/catch/finally works in coroutines because it’s just regular Kotlin code. But the problem is not syntax—it’s where exactly the error happens and who is supposed to see it.
In synchronous code, everything is simple: you call a function—it either returns a result or throws an exception “right now” at the call site. In the asynchronous world, you often do it differently: you start a task, keep going, and the error happens later, at another execution step. So the main question of this lecture is: at which point can your code actually catch an error, and what will happen to neighboring coroutines.
To avoid getting lost, we’ll rely on two facts from the coroutine model: coroutines are started via builders like launch() and async(), and their execution is tied to a context that contains a Job responsible for the lifecycle, and (in the general case) mechanisms for handling uncaught exceptions.
2. Practical example: a console report aggregator
So we don’t explain exceptions “in a vacuum,” we’ll evolve a small console app. Let it produce a report: it starts several subtasks concurrently (roughly: “fetch data,” “compute stats,” “compose text”), and then prints the result.
Let’s agree up front: we’re not using network, files, Flow, Channel, or other future-day magic. Instead, we simulate work via delay(...) and sometimes intentionally throw an exception to see how the system behaves.
Here are the basic stubs:
import kotlinx.coroutines.delay
suspend fun loadUsersCount(): Int {
delay(80)
return 120
}
suspend fun loadOrdersCount(): Int {
delay(60)
return 45
}
suspend fun loadRevenue(): Int {
delay(40)
error("Revenue service is down") // simulate a crash
}
We’ll call these functions in different ways—via launch and via async— and see where it’s convenient to catch an error, and where you end up with “oops.”
3. launch and async: action vs result
When you first see launch and async, it looks like they’re almost the same: both start concurrent work. But their error contract differs in exactly the same way as their result contract.
launch is “go do the work.” It returns a Job, i.e., a lifecycle handle: you can cancel it, you can wait for it with join(). But it does not return a value (semantically it’s fire-and-forget, though in structured concurrency you can’t “forget” it completely).
async is “go do the work and bring me a value.” It returns Deferred<T>: like a “promise of a result.” That means in the async world you get a result retrieval point: await().
From a learning-model perspective, a handy mnemonic is: launch is for actions, async is for results. Both are started from a CoroutineScope (builder methods), but they behave differently when an error occurs.
Let’s put that into a table—not to memorize, but to have something to glance at:
| What we start | What we control | Where the result “lives” | Where the error usually surfaces |
|---|---|---|---|
|
|
no result | most often “inside” the coroutine or at the scope boundary if not caught |
|
|
|
often in (or at the scope boundary if is not called) |
4. Errors in launch: why try/catch around launch doesn’t work
When someone first writes this, they expect everything to be fine:
import kotlinx.coroutines.*
fun main() = runBlocking {
try {
launch {
delay(10)
error("Boom from launch")
}
} catch (e: Exception) {
println("Caught: ${e.message}")
}
}
And then they’re surprised that catch didn’t trigger (or triggered not where they expected). The reason is simple: launch { ... } does not execute the body right now. It starts a coroutine and returns a Job almost instantly. And the error happens later—inside another logical “branch of execution.”
The correct handling logic for launch is usually one of two options.
Option A: catch the error inside launch
This option fits when the error is expected and you want the parent scenario to keep running.
import kotlinx.coroutines.*
fun main() = runBlocking {
val job: Job = launch {
try {
delay(20)
error("Boom from launch")
} catch (e: Exception) {
println("Handled inside launch: ${e.message}") // Handled inside launch: Boom from launch
}
}
job.join()
println("runBlocking continues") // runBlocking continues
}
Note an important detail: we waited for job.join(). This is not “about errors,” it’s about discipline: if you started a task and it matters that it finished—wait for it.
Option B: catch the error at the scenario boundary
If the error is unexpected (or you don’t want to “swallow” it inside), you often catch it at the top boundary where the scenario is started.
import kotlinx.coroutines.*
fun main() {
try {
runBlocking {
launch {
delay(10)
error("Unhandled error in launch")
}
delay(50)
println("This line may not run") // most likely won't run
}
} catch (e: Exception) {
println("Caught outside runBlocking: ${e.message}")
// Caught outside runBlocking: Unhandled error in launch
}
}
Why can you catch it this way? Because runBlocking is an “owning” boundary: it waits for child coroutines to finish and cannot pretend nothing happened. This is part of the structured concurrency idea: coroutines live inside managed lifecycle boundaries.
5. Errors in async: why they’re caught at await()
With async, you get a convenient point: await(). And that makes error handling much more “targeted”: you catch the exception where you actually obtain the result and understand the context.
Let’s make our “report aggregator”: it needs to get three numbers and build a string.
import kotlinx.coroutines.*
fun main() = runBlocking {
val usersDeferred: Deferred<Int> = async { loadUsersCount() }
val ordersDeferred: Deferred<Int> = async { loadOrdersCount() }
val revenueDeferred: Deferred<Int> = async { loadRevenue() }
try {
val users = usersDeferred.await()
val orders = ordersDeferred.await()
val revenue = revenueDeferred.await() // we fail here
println("Report: users=$users, orders=$orders, revenue=$revenue")
} catch (e: Exception) {
println("Report failed on await: ${e.message}")
// Report failed on await: Revenue service is down
}
}
The nice part of this approach: an error in async doesn’t “teleport” to some random place. It becomes visible at the point where you requested the result via await().
It’s important to remember that async is not a “magic safe” from errors. If an async coroutine fails, it still affects the parent scope (in the default mode), because it’s part of the task hierarchy, and that hierarchy is built via the Job in the context.
6. When and where an exception surfaces
Moment of surfacing: launch vs async
Now we’ll compress it into a human formula—non-academic, but accurate.
In launch, an error surfaces as “the coroutine died with an exception.” If you didn’t catch it inside, it will “raise the alarm” in the parent scope and may crash the whole scenario. It’s hard to point to a place in code where you “receive a result,” because there is no result.
In async, the error is often “packaged” into the Deferred and becomes explicit in await(). So you get a natural style: “got the result—handled the error.” This is especially convenient when you aggregate several results and want to form a clear message to the user.
Mini diagram to lock it in:
flowchart TD
A[Launch in scope] --> B1[launch]
A --> B2[async]
B1 --> C1[error occurs inside a coroutine]
C1 --> D1[if not caught inside -> crashes at the scope boundary]
B2 --> C2[error occurs inside a coroutine]
C2 --> D2["await() -> exception occurs here"]
D2 --> E2[can be caught with a try/catch around the await]
async without await(): the error surfaces at the scope boundary
This part matters specifically for beginners because it catches a common trap: “I created async, so I’ve already handled everything.” No.
If you created a Deferred but didn’t call await(), you effectively postponed the moment when the error becomes convenient to handle. Under structured concurrency, the error may still “bubble up” at the scope boundary, but the point will be less obvious: you’ll catch it not where you’re trying to get the result, but “when the scope was finishing.”
Let’s show it:
import kotlinx.coroutines.*
fun main() {
try {
runBlocking {
coroutineScope {
async {
delay(10)
error("Async failed")
}
// no await()
}
println("After coroutineScope") // most likely won't run
}
} catch (e: Exception) {
println("Caught at scope boundary: ${e.message}")
// Caught at scope boundary: Async failed
}
}
Practical takeaway: if you use async, treat await() as a required part of the contract. Not just “to get a number,” but to make the error-handling point explicit.
Why we catch launch inside, but async at the waiting point
It’s important not to confuse cause and effect here. We don’t catch async at await “by tradition.” It’s simply that async gives us a natural “hook”—getting a result. And launch gives no result, so “waiting” (join) doesn’t carry information about the cause.
join() is “wait for completion,” not “get a value.” And if you want to turn a launch failure into a controlled scenario (for example, to print “failed to complete step X”), you usually do it inside the body of launch.
By the way, this is another reason why in structured code it matters to distinguish Job and Deferred: Job is lifecycle control, Deferred is lifecycle plus result.
7. Where it’s best to catch an error
The question “where to catch?” is really about responsibility. You can catch the same bug in three places—and each can be correct or incorrect in its own way.
Inside the coroutine
This is done when the error is expected and you can continue. For example, “data unavailable—show a default.”
import kotlinx.coroutines.*
suspend fun safeLoadRevenue(): Int {
return try {
loadRevenue()
} catch (e: Exception) {
println("Revenue fallback: ${e.message}") // Revenue fallback: Revenue service is down
0
}
}
fun main() = runBlocking {
val revenue = safeLoadRevenue()
println("Revenue=$revenue") // Revenue=0
}
The plus of this approach is that the top-level code becomes simpler. The minus is that you may “sweep the problem under the rug” if you do a fallback where it’s not appropriate.
At await()
This is done when async is part of a business operation and you want to form a clear message: what exactly failed.
import kotlinx.coroutines.*
fun main() = runBlocking {
val revenueDeferred = async { loadRevenue() }
val revenue: Int? = try {
revenueDeferred.await()
} catch (e: Exception) {
println("Can't build report: revenue failed") // Can't build report: revenue failed
null
}
println("Revenue in report = $revenue") // Revenue in report = null
}
This approach is good because the error is handled next to the result usage: it’s clear to the reader what’s going on.
At the scenario boundary
This is done when the error is truly fatal or you don’t know how to handle it locally. Then it’s better to honestly terminate the scenario and print a clear message.
8. Mini refactor: buildReportOrNull() and diagnostics
Assembling buildReportOrNull()
Let’s assemble a piece of the app so it looks like real code: the function builds a report and either returns a string, or reports that it didn’t work out.
We’re not introducing Result yet and not designing a full error model—that will come later in the course. For now, we’ll use plain try/catch and null.
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
suspend fun buildReportOrNull(): String? = coroutineScope {
val usersD = async { loadUsersCount() }
val ordersD = async { loadOrdersCount() }
val revenueD = async { loadRevenue() }
try {
val users = usersD.await()
val orders = ordersD.await()
val revenue = revenueD.await()
"Report: users=$users, orders=$orders, revenue=$revenue"
} catch (e: Exception) {
println("buildReport failed: ${e.message}") // buildReport failed: Revenue service is down
null
}
}
And now main:
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val report = buildReportOrNull()
println(report ?: "No report today") // No report today
}
Why is this a good style for the current level? Because buildReportOrNull() creates a clear boundary: this is exactly where we decide what to do with errors in this scenario. And main then simply prints the result.
How to read a stack trace if the error “came in” from a coroutine
At this point, a common human reaction appears: “I didn’t understand anything; it crashed somewhere in space.” That’s normal. Coroutines add another layer: execution can switch between suspension points, and the stack looks unfamiliar.
But the core principle from exceptions remains the same: read the exception message and look for the first “your” line in the stack. If you catch the error at the boundary (for example, around runBlocking), the stack trace is usually informative enough—especially if you don’t hide the exception with an empty catch.
The key discipline for today: don’t write an empty catch, because otherwise you literally throw away the only decent explanation of “what broke.”
9. Typical mistakes when working with exceptions in launch and async
Mistake #1: expecting try/catch around launch { ... } to catch an error from the coroutine body.
This is a common logical trap: launch returns immediately, and the exception happens later, so the catch around the launch call usually catches only errors of coroutine creation (which is rare), not execution errors. If you need to handle a launch error locally, do try/catch inside the coroutine body or catch it at the outer boundary of the whole scenario.
Mistake #2: using launch when you actually need a result, and then trying to extract a value from Job.
Job is about lifecycle, not value. If you need a number/string/object— that’s a job for async and await(). Attempts to “bypass the contract” usually end with globals, mutability, and very sad debugging.
Mistake #3: creating a Deferred via async and forgetting to call await().
Then you lose the clear point where the error surfaces, and you risk a crash at the scope boundary in an unexpected place. In structured code, “not awaiting” is almost always a sign of unfinished logic: either you don’t use the result, or you don’t control errors.
Mistake #4: swallowing exceptions for peace of mind (catch {} or println("error") without details).
When you catch an exception and do nothing (or do too little), you save yourself two seconds now and buy an hour of pain later. The minimally useful action is to print the message (e.message) and understand which step of the scenario failed. Otherwise you deprive yourself of diagnostics, and the app starts behaving like a silent refrigerator: it seems to work, but why it’s not cooling is a mystery.
Mistake #5: catching too broadly and turning any error into “oh well, return 0.”
Fallback values are a normal tool, but only if you’re sure the scenario can truly continue. If you turn any error into 0 or null, you may silently build a report with incorrect data. Sometimes it’s better to honestly stop the scenario at the boundary (for example, by throwing outward) and tell the user the operation was not completed.
Mistake #6: mixing cancellation and a regular error by handling everything with one catch (Exception).
Coroutine cancellation is technically implemented via the exception mechanism too, but semantically it’s a separate lifecycle-control scenario: “we decided to stop,” not “we broke.” We’ll cover this properly in the next lecture on cleanup and try/finally, but even now it’s useful to keep in mind: not every exception means a bug—sometimes it’s just the signal “the operation was canceled.”
GO TO FULL VERSION