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
  • CoroutineExceptionHandler is a last-resort handler for launch — great for logging and crash reporting
  • CoroutineExceptionHandler does not work with async — use try-catch around await() instead
  • async stores exceptions in the Deferred — they're thrown at await(), not at the async block
  • SupervisorJob isolates child failures — one child failing doesn't cancel siblings
  • viewModelScope already uses SupervisorJob — each launch is independent
  • supervisorScope { } creates a local supervisor scope for independent parallel tasks
  • The sealed Result<T> + safeApiCall pattern 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!