Exception handling in Kotlin Coroutines is often misunderstood, especially when dealing with structured concurrency, exception propagation, and parallel execution. A poorly handled coroutine failure can crash your Android app or lead to silent failures, making debugging difficult.
In this article, we will cover advanced scenarios of exception handling, including:
✅ How exceptions propagate in coroutine hierarchies
✅ Handling exceptions in async
, launch
, and supervisorScope
✅ Managing errors in Flow
, SharedFlow
, and StateFlow
✅ Retrying failed operations with exponential backoff
✅ Best practices for handling exceptions in ViewModel and WorkManager
By the end of this guide, you’ll be confidently handling coroutine failures in any Android project. 🚀
Kotlin coroutines follow structured concurrency, meaning that when a parent coroutine is canceled, all of its children are also canceled. Likewise, when a child coroutine fails, the failure propagates up the hierarchy, canceling the entire coroutine scope.
Example: How a Failure in a Child Cancels the Entire Scope
val scope = CoroutineScope(Job())scope.launch {
launch {
delay(500)
throw IllegalArgumentException("Child coroutine failed")
}
delay(1000)
println("This line will never execute")
}
⏳ What happens?
- The child coroutine throws an exception.
- The parent scope is canceled, and no other coroutines in the scope continue execution.
Solution: Use supervisorScope
to Prevent Propagation
To prevent failures from canceling all coroutines, wrap them inside a supervisorScope
:
scope.launch {
supervisorScope {
launch {
delay(500)
throw IllegalArgumentException("Child coroutine failed")
}
delay(1000)
println("This line will still execute")
}
}
📌 Key Takeaway: Use supervisorScope
when you want sibling coroutines to run independently, even if one fails.
How launch
and async
Handle Exceptions
launch {}
immediately cancels the parent scope if an exception is thrown.async {}
delays exception propagation untilawait()
is called.
Example: launch
Cancels Everything on Failure
scope.launch {
launch {
throw IOException("Network error")
}
delay(1000) // This will never execute
}
Example: async
Hides the Exception Until await()
val deferred = scope.async {
throw NullPointerException("Async failed")
}
deferred.await() // Exception is thrown here
🚨 Danger: If you forget to call await()
, the exception is silently ignored.
Solution: Wrap await()
Calls in Try-Catch
try {
val result = deferred.await()
} catch (e: Exception) {
Log.e("Coroutine", "Handled exception: $e")
}
📌 Key Takeaway: Always wrap await()
in a try-catch
block to prevent unhandled exceptions.
In Android development, viewModelScope
is used to launch coroutines in ViewModels. However, uncaught exceptions in viewModelScope
crash the app unless properly handled.
Example: Crashing ViewModel Without Handling
class MyViewModel : ViewModel() {
fun fetchData() {
viewModelScope.launch {
throw IOException("Network failure")
}
}
}
📌 Problem: The exception is uncaught and crashes the app.
Solution: Use CoroutineExceptionHandler
class MyViewModel : ViewModel() {
private val handler = CoroutineExceptionHandler { _, throwable ->
Log.e("Coroutine", "Caught: $throwable")
}fun fetchData() {
viewModelScope.launch(handler) {
throw IOException("Network failure")
}
}
}
📌 Key Takeaway: Always attach a CoroutineExceptionHandler
to prevent crashes.
When executing multiple tasks in parallel, one coroutine failing cancels the others.
Example: One Failing Coroutine Cancels the Other
val result1 = async { fetchUserData() }
val result2 = async { fetchPosts() }
val userData = result1.await() // If this fails, result2 is also canceled
val posts = result2.await()
📌 Problem: If fetchUserData()
fails, fetchPosts()
is also canceled.
Solution: Use supervisorScope
to Make Coroutines Independent
supervisorScope {
val userData = async { fetchUserData() }
val posts = async { fetchPosts() }try {
userData.await()
posts.await()
} catch (e: Exception) {
Log.e("Coroutine", "One coroutine failed, but the other continued")
}
}
📌 Key Takeaway: supervisorScope
ensures one failure does not cancel everything.
Flows stop execution if an exception occurs inside collect()
.
Example: Flow Crashes on Exception
flow {
emit(1)
throw IllegalStateException("Error in flow")
}.collect {
println(it) // This stops execution after first emit
}
Solution: Use catch {}
to Handle Flow Exceptions
flow {
emit(1)
throw IllegalStateException("Error in flow")
}
.catch { e -> Log.e("Flow", "Caught exception: $e") }
.collect { println(it) }
📌 Key Takeaway: Always use .catch {}
to handle errors inside a Flow.
If a coroutine fails due to a network error, we can retry with exponential backoff.
suspend fun fetchDataWithRetry(): String {
var attempt = 0
val maxAttempts = 3
while (attempt try {
return fetchUserData()
} catch (e: IOException) {
attempt++
delay(1000L * attempt) // Exponential backoff
}
}
throw IOException("Failed after 3 attempts")
}
📌 Key Takeaway: Implement retries with increasing delay to handle transient failures.
When using WorkManager with coroutines, exceptions inside workers do not automatically retry.
Example: A Worker That Fails Silently
class MyWorker(ctx: Context, params: WorkerParameters) :
CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
fetchData() // May fail
return Result.success()
}
}
📌 Problem: If fetchData()
fails, WorkManager does not retry.
Solution: Return Result.retry()
on Exception
override suspend fun doWork(): Result {
return try {
fetchData()
Result.success()
} catch (e: Exception) {
Result.retry() // Automatically retries on failure
}
}
📌 Key Takeaway: Use Result.retry()
to ensure automatic retries.
Mastering exception handling in coroutines ensures that your app is resilient, fault-tolerant, and reliable.
What tricky coroutine failures have you encountered? Let me know in the comments! 🚀
Dobri Kostadinov
Android Consultant | Trainer
Email me | Follow me on LinkedIn | Follow me on Medium | Buy me a coffee