Exception handling in coroutines is one of the most misunderstood topics in Android development. It looks like regular try-catch but behaves differently depending on how your coroutines are structured. Get it wrong and exceptions silently swallow errors, crash the app, or cancel unrelated work. Get it right and you have a rock-solid, predictable error handling strategy. This guide covers everything — how exceptions propagate, CoroutineExceptionHandler, SupervisorJob, and real Android patterns.
How Exceptions Propagate in Coroutines
When a coroutine throws an unhandled exception, it cancels itself and propagates the exception to its parent. The parent then cancels all its other children and propagates up further. This is called structured concurrency — failure in one coroutine can cascade upward.
val scope = CoroutineScope(Dispatchers.Main)
scope.launch {
launch {
delay(100)
throw RuntimeException("Child failed!") // ❌ exception here
}
launch {
delay(200)
println("I was also cancelled") // never runs — cancelled by sibling failure
}
println("Parent also cancelled") // never runs
}
// The entire scope's job tree is cancelled
This is intentional — coroutines assume if one child fails, the whole operation is compromised. But sometimes you want sibling coroutines to continue even when one fails. That's where SupervisorJob comes in.
try-catch Inside a Coroutine
The simplest approach — wrap the suspending code in a try-catch block inside the coroutine body:
viewModelScope.launch {
try {
val articles = repository.getArticles() // suspend fun — can throw
_uiState.value = uiState.value.copy(articles = articles)
} catch (e: IOException) {
_uiState.value = uiState.value.copy(error = "No internet connection")
} catch (e: HttpException) {
_uiState.value = uiState.value.copy(error = "Server error: ${e.code()}")
} catch (e: Exception) {
_uiState.value = uiState.value.copy(error = "Something went wrong")
} finally {
_isLoading.value = false // always runs — loading off regardless
}
}
This works well for isolated operations. The exception is caught locally — it doesn't propagate to the parent or cancel sibling coroutines.
Important: CancellationException must not be caught
// ❌ Never catch CancellationException and swallow it
viewModelScope.launch {
try {
doWork()
} catch (e: Exception) {
// This catches CancellationException too!
// Swallowing it breaks coroutine cancellation
log(e)
}
}
// ✅ Re-throw CancellationException always
viewModelScope.launch {
try {
doWork()
} catch (e: CancellationException) {
throw e // must re-throw — don't swallow cancellation
} catch (e: Exception) {
handleError(e)
}
}
// ✅ Or use a specific exception type — cleaner
viewModelScope.launch {
try {
doWork()
} catch (e: IOException) {
handleNetworkError(e)
}
}
CoroutineExceptionHandler
CoroutineExceptionHandler is a coroutine context element that catches unhandled exceptions from coroutines launched with launch. It's the global last-resort handler — useful for logging, crash reporting, and showing a fallback UI.
val handler = CoroutineExceptionHandler { coroutineContext, exception ->
println("Caught: ${exception.message}")
// Log to crash reporting, show error UI, etc.
}
val scope = CoroutineScope(Dispatchers.Main + handler)
scope.launch {
throw RuntimeException("Something went wrong")
}
// Caught: Something went wrong — scope continues running
CoroutineExceptionHandler only works with launch, not async
val handler = CoroutineExceptionHandler { _, e -> println("Handler: ${e.message}") }
val scope = CoroutineScope(Dispatchers.Main + handler)
// ✅ handler catches exceptions from launch
scope.launch {
throw RuntimeException("launch error")
}
// Handler: launch error
// ❌ handler does NOT catch exceptions from async — they're stored in Deferred
scope.launch {
val deferred = async {
throw RuntimeException("async error")
}
deferred.await() // exception thrown HERE at await(), not at async {}
// handler won't catch this — must use try-catch around await()
}
CoroutineExceptionHandler in Android ViewModel
class ArticleViewModel(private val repository: ArticleRepository) : ViewModel() {
private val exceptionHandler = CoroutineExceptionHandler { _, exception ->
_uiState.value = uiState.value.copy(
isLoading = false,
error = exception.message ?: "Unknown error"
)
// Also log to crash reporting
FirebaseCrashlytics.getInstance().recordException(exception)
}
fun loadArticles() {
viewModelScope.launch(exceptionHandler) {
_uiState.value = uiState.value.copy(isLoading = true)
val articles = repository.getArticles() // if this throws, handler catches it
_uiState.value = uiState.value.copy(
isLoading = false,
articles = articles
)
}
}
}
async and Exception Handling
async doesn't throw exceptions immediately — it stores them in the Deferred result. The exception is only thrown when you call await(). This means you must handle exceptions at the await() call site:
viewModelScope.launch {
// async stores exception — doesn't throw yet
val deferred = async {
repository.getArticles() // might throw
}
try {
val articles = deferred.await() // exception thrown HERE
showArticles(articles)
} catch (e: Exception) {
showError(e.message)
}
}
Parallel requests with async — handling individual failures
viewModelScope.launch {
// Start both requests in parallel
val articlesDeferred = async { repository.getArticles() }
val userDeferred = async { repository.getUser() }
// Handle each independently
val articles = try {
articlesDeferred.await()
} catch (e: Exception) {
emptyList() // fallback if articles fail
}
val user = try {
userDeferred.await()
} catch (e: Exception) {
null // fallback if user fails
}
_uiState.value = uiState.value.copy(articles = articles, user = user)
}
SupervisorJob — Isolate Failures
By default, if a child coroutine fails, it cancels its parent and all siblings. SupervisorJob changes this — a child failure does not propagate to the parent or cancel siblings. Each child is independent.
// Without SupervisorJob — one failure cancels everything
val scope = CoroutineScope(Dispatchers.Main)
scope.launch {
launch { throw RuntimeException("I failed") } // cancels all siblings
launch { println("I was cancelled too") } // never runs
}
// With SupervisorJob — failure isolated to that child
val supervisorScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
supervisorScope.launch {
launch { throw RuntimeException("I failed") } // only this one fails
launch { println("I still run!") } // ✅ runs normally
}
viewModelScope already uses SupervisorJob
// viewModelScope is defined as:
// CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
// So this is safe — one failing launch doesn't cancel others
class ArticleViewModel : ViewModel() {
fun loadAll() {
viewModelScope.launch {
loadArticles() // if this fails, loadUser still runs
}
viewModelScope.launch {
loadUser() // independent — not affected by other failures
}
}
}
supervisorScope function — local supervisor
viewModelScope.launch {
supervisorScope {
// Inside here — each child is independent
val articlesJob = launch {
try {
_articles.value = repository.getArticles()
} catch (e: Exception) {
_articlesError.value = e.message
}
}
val userJob = launch {
try {
_user.value = repository.getUser()
} catch (e: Exception) {
_userError.value = e.message
}
}
// Both run in parallel — failure in one doesn't cancel the other
}
}
Safe API Call Pattern
The most practical pattern for Android — wrap every network/database call in a safe wrapper that converts exceptions to a sealed Result type:
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Exception, val message: String) : Result<Nothing>()
object Loading : Result<Nothing>()
}
suspend fun <T> safeApiCall(
dispatcher: CoroutineDispatcher = Dispatchers.IO,
call: suspend () -> T
): Result<T> = withContext(dispatcher) {
try {
Result.Success(call())
} catch (e: CancellationException) {
throw e // always re-throw CancellationException
} catch (e: HttpException) {
Result.Error(e, "Server error: ${e.code()}")
} catch (e: IOException) {
Result.Error(e, "Network error — check your connection")
} catch (e: Exception) {
Result.Error(e, e.message ?: "Unknown error")
}
}
// Repository using safeApiCall
class ArticleRepository(private val api: ApiService) {
suspend fun getArticles(): Result<List<Article>> =
safeApiCall { api.getArticles() }
suspend fun getArticle(id: String): Result<Article> =
safeApiCall { api.getArticle(id) }
}
// ViewModel — clean, no try-catch clutter
class ArticleViewModel(private val repository: ArticleRepository) : ViewModel() {
fun loadArticles() {
viewModelScope.launch {
_uiState.value = uiState.value.copy(isLoading = true)
when (val result = repository.getArticles()) {
is Result.Success -> _uiState.value = uiState.value.copy(
isLoading = false,
articles = result.data
)
is Result.Error -> _uiState.value = uiState.value.copy(
isLoading = false,
error = result.message
)
is Result.Loading -> { /* handled above */ }
}
}
}
}
Exception Handling with Flow
Flow has its own exception handling operators:
// catch operator — handles upstream exceptions
repository.getArticlesFlow()
.catch { e ->
emit(emptyList()) // emit fallback value on error
// or just log and let flow complete
}
.collect { articles ->
_articles.value = articles
}
// onEach + catch pattern
repository.getArticlesFlow()
.onEach { articles ->
_articles.value = articles
}
.catch { e ->
_error.value = e.message
}
.launchIn(viewModelScope)
// retry on failure
repository.getArticlesFlow()
.retry(3) { e ->
e is IOException // only retry on network errors
}
.catch { e ->
_error.value = "Failed after 3 retries: ${e.message}"
}
.launchIn(viewModelScope)
Common Mistakes to Avoid
Mistake 1: Swallowing CancellationException
// ❌ Catches CancellationException — breaks structured concurrency
launch {
try { doWork() } catch (e: Exception) { log(e) } // swallows cancellation!
}
// ✅ Re-throw it
launch {
try {
doWork()
} catch (e: CancellationException) {
throw e // must re-throw
} catch (e: Exception) {
log(e)
}
}
Mistake 2: Using CoroutineExceptionHandler with async
val handler = CoroutineExceptionHandler { _, e -> log(e) }
// ❌ Handler doesn't catch async exceptions
scope.launch(handler) {
val result = async { throw RuntimeException("error") }
result.await() // exception here — handler won't catch it!
}
// ✅ Use try-catch around await()
scope.launch(handler) {
try {
val result = async { throw RuntimeException("error") }
result.await()
} catch (e: Exception) {
log(e)
}
}
Mistake 3: Not using SupervisorJob for independent parallel tasks
// ❌ One failure cancels all — not the desired behavior
val scope = CoroutineScope(Dispatchers.IO)
scope.launch { fetchArticles() }
scope.launch { fetchUser() } // cancelled if fetchArticles throws
scope.launch { fetchBanners() } // cancelled if fetchArticles throws
// ✅ SupervisorJob isolates failures
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope.launch { fetchArticles() }
scope.launch { fetchUser() } // independent — runs even if fetchArticles fails
scope.launch { fetchBanners() } // independent
Mistake 4: try-catch outside the coroutine builder
// ❌ try-catch outside launch doesn't catch exceptions inside the coroutine
try {
viewModelScope.launch {
throw RuntimeException("error") // not caught by outer try-catch
}
} catch (e: Exception) {
// never reaches here
}
// ✅ try-catch must be INSIDE the coroutine
viewModelScope.launch {
try {
throw RuntimeException("error")
} catch (e: Exception) {
handleError(e) // ✅ caught here
}
}
Summary
- Coroutine exceptions propagate upward to the parent by default, cancelling siblings — this is structured concurrency
- try-catch inside the coroutine body is the simplest approach — catches exceptions locally without cancelling the scope
- Always re-throw
CancellationException— swallowing it breaks coroutine cancellation CoroutineExceptionHandleris a last-resort handler forlaunch— great for logging and crash reportingCoroutineExceptionHandlerdoes not work withasync— use try-catch aroundawait()insteadasyncstores exceptions in theDeferred— they're thrown atawait(), not at theasyncblockSupervisorJobisolates child failures — one child failing doesn't cancel siblingsviewModelScopealready usesSupervisorJob— eachlaunchis independentsupervisorScope { }creates a local supervisor scope for independent parallel tasks- The sealed
Result<T>+safeApiCallpattern is the cleanest approach for Android — no scattered try-catch - Flow has
.catch { }and.retry()operators for upstream exception handling
Exception handling in coroutines rewards understanding the underlying model. Once you know how propagation works, when to use SupervisorJob, and why CancellationException is special, you can build error handling that's both robust and easy to reason about.
Happy coding!
Comments (0)