Kotlin
coroutines
exception-handling
try-catch
programming

Why i can't use try/catch to catch exception in Kotlin coroutine?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

You can use try/catch with Kotlin coroutines, but only where the exception is actually observed. The confusing part is that coroutine builders such as launch and async surface failures differently, so a try/catch around the wrong call site can look like it does nothing.

async and launch Do Not Report Failures the Same Way

async stores its result, including failure, inside a Deferred. You usually observe that failure when you call await().

launch does not return a value. If it fails, the exception is treated as an uncaught coroutine failure and is propagated to the parent scope.

Here is the key difference:

kotlin
1import kotlinx.coroutines.async
2import kotlinx.coroutines.launch
3import kotlinx.coroutines.runBlocking
4
5fun main() = runBlocking {
6    val deferred = async {
7        throw IllegalStateException("async failed")
8    }
9
10    try {
11        deferred.await()
12    } catch (e: IllegalStateException) {
13        println("Caught from async: ${e.message}")
14    }
15
16    val job = launch {
17        throw IllegalArgumentException("launch failed")
18    }
19
20    job.join()
21}

await() rethrows the failure from async, so try/catch works there. join() only waits for launch to finish. It does not rethrow the original exception for you.

Catch Inside the launch Body When Needed

If the failing code is inside a launch coroutine and you want local handling, the usual fix is to wrap the body itself:

kotlin
1import kotlinx.coroutines.launch
2import kotlinx.coroutines.runBlocking
3
4fun main() = runBlocking {
5    launch {
6        try {
7            error("boom")
8        } catch (e: Exception) {
9            println("Handled inside launch: ${e.message}")
10        }
11    }
12}

This works because the exception is thrown inside the scope surrounded by try/catch.

Use CoroutineExceptionHandler for Top-Level launch

For top-level launch coroutines, CoroutineExceptionHandler is often the right tool for centralized logging or fallback behavior.

kotlin
1import kotlinx.coroutines.CoroutineExceptionHandler
2import kotlinx.coroutines.GlobalScope
3import kotlinx.coroutines.launch
4
5val handler = CoroutineExceptionHandler { _, throwable ->
6    println("Caught by handler: ${throwable.message}")
7}
8
9fun main() {
10    GlobalScope.launch(handler) {
11        error("top-level launch failure")
12    }
13
14    Thread.sleep(500)
15}

This is not a substitute for local recovery logic, but it is how uncaught launch failures are typically observed at a scope boundary.

Structured Concurrency Changes What Gets Cancelled

Coroutines do not fail in isolation unless the scope structure allows them to. In structured concurrency, a failing child can cancel its parent and siblings unless supervision changes that behavior.

That is why exception handling is not only about syntax. It is also about coroutine hierarchy.

If one child failure should not cancel the rest, use supervision:

kotlin
1import kotlinx.coroutines.launch
2import kotlinx.coroutines.runBlocking
3import kotlinx.coroutines.supervisorScope
4
5fun main() = runBlocking {
6    supervisorScope {
7        launch { error("one child failed") }
8        launch { println("another child can continue") }
9    }
10}

Without supervision, a parent-child failure chain can make it look as though try/catch "did not work," when the real issue is failure propagation through the scope tree.

A Useful Debugging Rule

When coroutine exception handling feels inconsistent, ask two questions:

  • which builder created the coroutine
  • where does this builder surface failure

If the answer is async, look at await(). If the answer is launch, look at the coroutine body, the parent scope, or the exception handler attached to that scope. That mental model is usually enough to explain why a try/catch block appears to be ignored.

Common Pitfalls

  • Wrapping launch creation in try/catch and expecting join() to rethrow the exception.
  • Forgetting that await() is where async failure usually becomes observable.
  • Catching too far away from the actual suspension point or coroutine body.
  • Ignoring parent-child cancellation behavior in structured concurrency.
  • Using GlobalScope casually and then losing track of where failures are supposed to go.

Summary

  • 'try/catch works with coroutines, but only where the exception is surfaced.'
  • 'async failures are typically caught around await().'
  • 'launch failures are usually handled inside the coroutine body or through a CoroutineExceptionHandler.'
  • Coroutine hierarchy affects exception behavior just as much as syntax does.
  • When error handling seems broken, check both the builder type and the parent-child scope structure.

Course illustration
Course illustration

All Rights Reserved.