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
CoroutineExceptionHandlerinstalled but app still crashes- Exception from
asyncblock not caught by handler - Child coroutine exception cancels sibling coroutines
- Global exception handler catches exceptions but does not prevent crash
try-catcharoundlaunchdoes 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
CoroutineExceptionHandlerdoes not catchasyncexceptions (usetry-catcharoundawait)- Exception in child coroutine propagates to parent, not to sibling's handler
- Handler installed on child coroutine, not on the root coroutine
supervisorScopenot used when sibling coroutines should survive failures- Exception type is
CancellationExceptionwhich is never caught by design
Step-by-Step Fix
- 1.Install CoroutineExceptionHandler on the root coroutine:
- 2.```kotlin
- 3.// WRONG - handler on child coroutine, exception propagates to parent
- 4.val handler = CoroutineExceptionHandler { _, exception ->
- 5.Log.e("Coroutine", "Caught: ${exception.message}")
- 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.Handle async exceptions with try-catch around await:
- 2.```kotlin
- 3.// WRONG - CoroutineExceptionHandler does NOT catch async exceptions
- 4.val handler = CoroutineExceptionHandler { _, exception ->
- 5.Log.e("Coroutine", "Caught: ${exception.message}")
- 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.Use supervisorScope to isolate coroutine failures:
- 2.```kotlin
- 3.// WRONG - one failure cancels all siblings
- 4.GlobalScope.launch {
- 5.val job1 = launch { loadUserData() }
- 6.val job2 = launch { loadUserPosts() }
- 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.Set up global exception handler for uncaught coroutine exceptions:
- 2.```kotlin
- 3.// Application-level global handler
- 4.class MyApp : Application() {
- 5.override fun onCreate() {
- 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
CoroutineExceptionHandleron the root coroutine, not on children - Use
try-catcharoundawait()for async error handling — handlers do not catch async exceptions - Use
supervisorScopewhen 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
launchSafewrapper in ViewModels for centralized error handling - Test coroutine error paths with
runTestandTestDispatcher