Introduction

Kotlin coroutines use structured concurrency where exceptions in child coroutines propagate to their parent and cancel the entire coroutine hierarchy. The CoroutineExceptionHandler only catches exceptions that are not handled by the coroutine builder itself — it does not catch exceptions from async blocks (those are caught by await) and does not catch exceptions in nested child coroutines (those propagate to the parent handler). Understanding exception propagation in structured concurrency is essential for building crash-resistant coroutine code.

Symptoms

  • CoroutineExceptionHandler installed but app still crashes
  • Exception from async block not caught by handler
  • Child coroutine exception cancels sibling coroutines
  • Global exception handler catches exceptions but does not prevent crash
  • try-catch around launch does not catch exception from inside

Error output: `` FATAL EXCEPTION: main kotlinx.coroutines.JobCancellationException: Job was cancelled at kotlinx.coroutines.JobSupport.cancel --- Exception not caught by CoroutineExceptionHandler ---

Common Causes

  • CoroutineExceptionHandler does not catch async exceptions (use try-catch around await)
  • Exception in child coroutine propagates to parent, not to sibling's handler
  • Handler installed on child coroutine, not on the root coroutine
  • supervisorScope not used when sibling coroutines should survive failures
  • Exception type is CancellationException which is never caught by design

Step-by-Step Fix

  1. 1.Install CoroutineExceptionHandler on the root coroutine:
  2. 2.```kotlin
  3. 3.// WRONG - handler on child coroutine, exception propagates to parent
  4. 4.val handler = CoroutineExceptionHandler { _, exception ->
  5. 5.Log.e("Coroutine", "Caught: ${exception.message}")
  6. 6.}

GlobalScope.launch { launch(handler) { // Handler here does NOT catch this exception throw IOException("Network error") } } // Exception propagates to GlobalScope's default handler -> crash

// CORRECT - handler on the ROOT coroutine val handler = CoroutineExceptionHandler { _, exception -> Log.e("Coroutine", "Caught: ${exception.message}") }

GlobalScope.launch(handler) { launch { // Child - exception propagates to parent's handler throw IOException("Network error") } } // Handler catches the exception

// In Android/ViewModel scope class MyViewModel : ViewModel() { private val exceptionHandler = CoroutineExceptionHandler { _, exception -> Log.e("ViewModel", "Coroutine failed", exception) _uiState.value = UiState.Error(exception.message ?: "Unknown error") }

fun loadData() { viewModelScope.launch(exceptionHandler) { repository.fetchData() // If this throws, handler catches it } } } ```

  1. 1.Handle async exceptions with try-catch around await:
  2. 2.```kotlin
  3. 3.// WRONG - CoroutineExceptionHandler does NOT catch async exceptions
  4. 4.val handler = CoroutineExceptionHandler { _, exception ->
  5. 5.Log.e("Coroutine", "Caught: ${exception.message}")
  6. 6.}

GlobalScope.launch(handler) { val deferred = async { throw IOException("Network error") } // Exception is NOT thrown here - it's stored in deferred // deferred.await() throws the exception, but handler doesn't catch it }

// CORRECT - use try-catch around await GlobalScope.launch { val deferred = async { fetchDataFromNetwork() }

try { val result = deferred.await() // Exception thrown HERE processResult(result) } catch (e: IOException) { Log.e("Async", "Network error", e) showError("Failed to load data") } }

// OR use awaitAll with structured error handling GlobalScope.launch { val deferred1 = async { fetchUserData() } val deferred2 = async { fetchUserPosts() }

try { val results = awaitAll(deferred1, deferred2) // All completed successfully } catch (e: Exception) { // One or both failed Log.e("Async", "Fetch failed", e) } } ```

  1. 1.Use supervisorScope to isolate coroutine failures:
  2. 2.```kotlin
  3. 3.// WRONG - one failure cancels all siblings
  4. 4.GlobalScope.launch {
  5. 5.val job1 = launch { loadUserData() }
  6. 6.val job2 = launch { loadUserPosts() }
  7. 7.val job3 = launch { loadUserComments() }

// If job1 throws, job2 and job3 are cancelled too }

// CORRECT - supervisorScope isolates failures GlobalScope.launch { supervisorScope { // Each child runs independently launch { try { loadUserData() } catch (e: Exception) { Log.e("Scope", "User data failed", e) } } launch { try { loadUserPosts() } catch (e: Exception) { Log.e("Scope", "User posts failed", e) } } launch { try { loadUserComments() } catch (e: Exception) { Log.e("Scope", "Comments failed", e) } } } }

// supervisorScope with async supervisorScope { val userData = async { try { fetchUserData() } catch (e: Exception) { Log.e("Async", "User data failed", e) null // Return null on failure } }

val userPosts = async { fetchUserPosts() // If this throws, userData is NOT cancelled }

val userDataResult = userData.await() // null on failure val posts = userPosts.await() // Still runs even if userData failed } ```

  1. 1.Set up global exception handler for uncaught coroutine exceptions:
  2. 2.```kotlin
  3. 3.// Application-level global handler
  4. 4.class MyApp : Application() {
  5. 5.override fun onCreate() {
  6. 6.super.onCreate()

Thread.setDefaultUncaughtExceptionHandler { thread, exception -> Log.e("Global", "Uncaught exception on ${thread.name}", exception) // Send to crash reporting (Firebase Crashlytics, etc.) FirebaseCrashlytics.getInstance().recordException(exception) }

// For coroutines specifically CoroutineExceptionHandler { _, exception -> Log.e("GlobalCoroutine", "Uncaught coroutine exception", exception) } } }

// For ViewModel - centralize error handling abstract class BaseViewModel : ViewModel() { protected val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception -> Log.e("BaseViewModel", "Coroutine error", exception) _errorChannel.trySend(exception) }

protected fun launchSafe( block: suspend CoroutineScope.() -> Unit ) { viewModelScope.launch(coroutineExceptionHandler) { block() } } }

// Usage class UserViewModel : BaseViewModel() { fun loadUser() = launchSafe { val user = repository.getUser() _userState.value = UiState.Success(user) } } ```

Prevention

  • Install CoroutineExceptionHandler on the root coroutine, not on children
  • Use try-catch around await() for async error handling — handlers do not catch async exceptions
  • Use supervisorScope when sibling coroutines should survive each other's failures
  • Never catch CancellationException — it is the normal cancellation mechanism
  • Add a global exception handler for crash reporting
  • Use launchSafe wrapper in ViewModels for centralized error handling
  • Test coroutine error paths with runTest and TestDispatcher