13 Common Kotlin Coroutine Exception Scenarios and How to Fix Them
This article walks through thirteen typical Kotlin coroutine exception pitfalls, explains why errors propagate in structured concurrency, shows wrong and correct code patterns for launch, async, coroutineScope, supervisorScope, and other utilities, and provides concrete examples with outputs to help developers write robust coroutine code.
In Kotlin coroutines, exception handling follows the principle of structured concurrency: an error in a child coroutine must be caught either inside that child or by its parent; otherwise it propagates upward and can crash the whole program.
Scenario 1: launch coroutine pitfalls
launchis a fire‑and‑forget coroutine; wrapping it with try‑catch outside has no effect because launch returns immediately and the exception occurs in a separate coroutine.
import kotlinx.coroutines.*
fun main() = runBlocking {
try {
// wrong: try‑catch outside launch
launch {
println("before launch")
throw RuntimeException("launch operation failed...")
}
} catch (e: Exception) {
println("Caught: ${e.message}")
}
println("after launch")
delay(100)
}The program crashes without printing the catch block.
Correct usage: place try‑catch inside the launch lambda.
import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
try {
println("before launch")
throw RuntimeException("launch operation failed...")
} catch (e: Exception) {
println("Caught inside launch: ${e.message}")
}
}
println("after launch")
delay(100)
}Now the exception is handled and the program finishes normally.
Scenario 2: async “deferred” exception
asyncreturns a Deferred; the exception is stored inside it and is only thrown when await() is called.
fun main() = runBlocking {
println("before async")
val deferred = async {
throw RuntimeException("async operation failed...")
}
println("after async")
try {
deferred.await()
} catch (e: Exception) {
println("Caught async exception: ${e.message}")
}
println("Program ends")
}This shows that the exception is caught at await().
Scenario 3: coroutineScope “all‑or‑nothing”
When a child coroutine inside coroutineScope fails, the whole scope is cancelled, and the parent receives the exception.
fun main() = runBlocking {
try {
coroutineScope {
launch { println("Child 1 start"); delay(1000); println("Child 1 done") }
launch { println("Child 2 start"); throw RuntimeException("Child 2 fails") }
}
} catch (e: Exception) {
println("Parent caught: ${e.message}")
}
println("Scope ends")
}Child 2 failure cancels Child 1.
Scenario 4: supervisorScope isolates failures
supervisorScopeprevents a failing child from cancelling its siblings; each child must handle its own exceptions.
fun main() = runBlocking {
supervisorScope {
launch {
try {
println("Child 1 start")
throw RuntimeException("Child 1 fails")
} catch (e: Exception) {
println("Child 1 caught: ${e.message}")
}
}
launch { println("Child 2 runs normally") }
}
println("Supervisor ends")
}Scenario 5: CoroutineExceptionHandler as a global fallback
Defines a handler for uncaught exceptions in launch coroutines.
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, throwable ->
println("Global handler caught: ${throwable.message}")
}
launch(handler) { throw RuntimeException("launch uncaught") }
delay(100)
println("Program ends")
}Scenario 6: async inside supervisorScope
Even inside supervisorScope, async exceptions are stored in the Deferred and must be handled when await() is called.
fun main() = runBlocking {
supervisorScope {
val d1 = async { throw RuntimeException("async1 fails") }
val d2 = async { "async2 succeeds" }
try { d1.await() } catch (e: Exception) { println("Caught async1: ${e.message}") }
println("Result of async2: ${d2.await()}")
}
println("supervisorScope finished")
}Scenario 7: CancellationException as a special case
When a coroutine is cancelled it throws CancellationException. It can be caught for cleanup, but should usually be re‑thrown to let cancellation propagate.
fun main() = runBlocking {
val job = launch {
try {
println("Job working...")
delay(1000)
} catch (e: CancellationException) {
println("Job cancelled: ${e.message}")
throw e
} finally {
println("Job finally block (cleanup)")
}
}
delay(500)
println("Cancelling job")
job.cancelAndJoin()
println("Job is cancelled")
}Scenario 8: NonCancellable for guaranteed cleanup
If a finally block contains suspending calls, wrap them with withContext(NonCancellable) so they run even after cancellation.
fun main() = runBlocking {
val job = launch {
try {
delay(1000)
} finally {
withContext(NonCancellable) {
println("Running critical cleanup...")
delay(500)
println("Critical cleanup done")
}
println("Regular cleanup (optional)")
}
}
delay(200)
println("Cancelling job")
job.cancelAndJoin()
println("Job cancelled")
}Scenario 9: Nested scopes and exception propagation
When a coroutineScope is nested inside a supervisorScope, the inner scope’s failure cancels its own children but does not affect siblings in the outer supervisor.
fun main() = runBlocking {
supervisorScope {
launch { println("Supervisor child survives") }
launch {
coroutineScope {
launch { println("Inner sibling will be cancelled") }
launch { throw RuntimeException("Inner failure") }
}
}
}
println("All done")
}Scenario 10: Job hierarchy fundamentals
Each coroutine has a Job. A parent job waits for all its children and cancels them if any child fails (unless the parent is a supervisor).
fun main() = runBlocking {
println("Parent scope start")
val job1 = launch { delay(1000); println("Child 1 done") }
val job2 = launch {
launch { delay(500); println("Grandchild done") }
println("Child 2 waiting for grandchild")
}
// runBlocking waits for both job1 and job2
println("Parent scope end")
}Scenario 11: supervisorScope vs CoroutineScope(SupervisorJob())
supervisorScope { … }creates a local isolated block; an uncaught exception still propagates outward. CoroutineScope(SupervisorJob()) creates a reusable scope where failures do not cancel the whole scope.
val componentScope = CoroutineScope(SupervisorJob())
fun startRiskyTask() = componentScope.launch { throw RuntimeException("Risky task failed") }
fun startSafeTask() = componentScope.launch { println("Safe task succeeds") }
fun main() = runBlocking {
startRiskyTask()
startSafeTask()
delay(500)
componentScope.cancel()
}Scenario 12: Handling timeouts
Use withTimeout to throw TimeoutCancellationException or withTimeoutOrNull to return null on timeout.
fun main() = runBlocking {
try {
withTimeout(1000) { println("Task 1 start"); delay(2000) }
} catch (e: TimeoutCancellationException) {
println("Task 1 timed out")
}
val result = withTimeoutOrNull(1000) { println("Task 2 start"); delay(2000); "Result" }
println("Task 2 result: $result")
}Scenario 13: awaitAll() fast‑failure behavior
When awaiting multiple Deferred values with awaitAll(), the first exception cancels the remaining jobs and is re‑thrown.
fun main() = runBlocking {
val tasks = listOf(
async { delay(100); println("Task 1 success"); "R1" },
async { delay(50); println("Task 2 fails"); throw RuntimeException("Task 2 error") },
async { delay(200); println("Task 3 would succeed") }
)
try {
tasks.awaitAll()
} catch (e: Exception) {
println("Caught: ${e.message}")
}
}The article enumerates these thirteen typical pitfalls, provides wrong and correct code examples, and clarifies how structured concurrency, supervisor scopes, timeout utilities, and job hierarchies work together to write robust Kotlin coroutine code.
AndroidPub
Senior Android Developer & Interviewer, regularly sharing original tech articles, learning resources, and practical interview guides. Welcome to follow and contribute!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
