Kotlin coroutines bring elegant asynchronous programming to Android and backend services, but they come with a gotcha: exceptions can silently disappear if you don't handle them deliberately. A coroutine launched without proper error handling can crash without ever reaching your global exception handler or crash reporting tool. This guide walks through Kotlin coroutine CoroutineExceptionHandler crash prevention patterns, structured concurrency principles, and real code you can use today to stop exceptions from vanishing into the void.
The core issue is that coroutines follow structured concurrency—they live inside scopes and contexts. An uncaught exception in a child coroutine doesn't automatically bubble to your app's uncaught exception handler; instead, it cancels the scope and can be silently swallowed. This is especially dangerous in production, where silent crashes mean you never know a feature broke.
Understanding CoroutineExceptionHandler
CoroutineExceptionHandler is a coroutine context element—an interceptor for uncaught exceptions. It's the standard way to catch exceptions that would otherwise crash your coroutine scope.
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
println("Caught: ${exception.message}")
// Send to error tracker, log, recover gracefully
}
val scope = CoroutineScope(Dispatchers.Main + exceptionHandler)
scope.launch {
throw RuntimeException("This will be caught")
}
The handler receives the coroutine context and the exception. Use it to log, notify the user, or report to an error-tracking service like LightTrace. The key: a CoroutineExceptionHandler only works on coroutines launched with launch()—not async().
launch() vs async(): Why Exception Handling Differs
This is the trap many developers fall into. launch() and async() behave differently when exceptions occur.
launch() propagates uncaught exceptions to the CoroutineExceptionHandler in its context. If no handler exists, the exception crashes the scope:
val handler = CoroutineExceptionHandler { _, exception ->
println("Handler caught: $exception")
}
val scope = CoroutineScope(Dispatchers.Main + handler)
scope.launch {
delay(100)
throw RuntimeException("From launch")
// CoroutineExceptionHandler intercepts this
}
async() wraps exceptions inside the returned Deferred. No exception handler will catch them—the exception is only thrown when you call .await(). This makes async() safer for structured code but requires explicit error handling at the call site:
val deferred = scope.async {
delay(100)
throw RuntimeException("From async")
}
// Exception only surfaces here
try {
deferred.await()
} catch (e: Exception) {
println("Caught via try-catch: $e")
}
If you launch async() and never call .await(), the exception is silently dropped. Always use try-catch or .getCompletionExceptionOrNull() when working with async. For unchecked fire-and-forget launches, always provide a CoroutineExceptionHandler.
Never mix launch() without an exception handler and assume exceptions will reach your app's global handler. They won't—structured concurrency isolates failures.
Structured Concurrency and Scope Hierarchy
Coroutines form a tree: a parent scope creates child coroutines. When a child throws an uncaught exception, it cancels all siblings and the parent (unless the exception is handled). This is called failure propagation.
val parentScope = CoroutineScope(Dispatchers.Main)
parentScope.launch {
// Child 1
launch {
delay(1000)
throw RuntimeException("Child 1 failed")
}
// Child 2 will be cancelled when Child 1 throws
launch {
delay(500)
println("Child 2 still running")
}
}
To prevent a single failing coroutine from killing the parent, use a CoroutineExceptionHandler scoped to that launch:
val errorHandler = CoroutineExceptionHandler { _, exception ->
// Log locally, report to tracking
}
parentScope.launch {
launch(errorHandler) {
throw RuntimeException("Isolated failure")
}
launch {
// This continues normally
}
}
Use supervisorScope or SupervisorJob if you want children to fail independently without cascading to siblings.
Flow Error Handling Patterns
Reactive Flows are coroutine channels that emit values over time. Exceptions in Flows need different handling because they're not bound to a single launch:
flow {
emit(1)
emit(2)
throw RuntimeException("Flow failed")
emit(3) // Never emitted
}.catch { exception ->
println("Caught in Flow: $exception")
}.collect { value ->
println(value)
}
The .catch() operator intercepts exceptions and lets you recover—emit fallback values, log, or retry. For comprehensive error handling in Flows:
data class Result<T>(val data: T? = null, val error: Throwable? = null)
fun safeFlow(): Flow<Result<String>> = flow {
try {
emit(Result(data = "Success"))
throw RuntimeException("Processing failed")
} catch (e: Exception) {
emit(Result(error = e))
}
}
This pattern lets your collector handle success or failure gracefully without crashes.
Error Tracking Integration
Once you've caught an exception, send it to LightTrace. Install the Sentry SDK (which LightTrace is compatible with) and configure it with your LightTrace DSN:
import io.sentry.Sentry
// In your app initialization
Sentry.init { options ->
options.dsn = "https://<key>@light-trace.robomiri.com/1"
options.tracesSampleRate = 1.0
}
val handler = CoroutineExceptionHandler { _, exception ->
// Capture in LightTrace
Sentry.captureException(exception)
Log.e("Coroutine", "Exception caught", exception)
}
Now every uncaught exception in your scoped coroutines is reported to your LightTrace dashboard. You'll see stack traces, breadcrumbs (logs leading up to the crash), affected users, and can link directly to the source code in GitHub.
Best practice: pair CoroutineExceptionHandler with error tracking. Handling an exception locally without reporting it means you're flying blind in production.
Common Patterns and Pitfalls
Pitfall 1: Forgetting the handler in viewModelScope
Android's viewModelScope has a default SupervisorJob, which isolates failures, but without a handler you still won't see them:
class MyViewModel : ViewModel() {
private val exceptionHandler = CoroutineExceptionHandler { _, exception ->
Sentry.captureException(exception)
}
fun loadData() {
viewModelScope.launch(exceptionHandler) {
// Now exceptions are tracked
}
}
}
Pitfall 2: Using async without await
If you fire off an async() and ignore the result, the exception is lost:
// Bad: exception is dropped
scope.async {
throw RuntimeException("Forgotten")
}
// Good: exception is caught
val deferred = scope.async {
throw RuntimeException("Remembered")
}
try {
deferred.await()
} catch (e: Exception) {
handleError(e)
}
Pitfall 3: Mixing coroutine scopes without clarity
Use one clear scope for your lifecycle (viewModelScope, lifecycleScope) and add handlers consistently.
Testing Exception Handlers
Unit test your handlers to ensure they actually catch exceptions:
@Test
fun testExceptionHandling() = runTest {
val exceptions = mutableListOf<Throwable>()
val handler = CoroutineExceptionHandler { _, exception ->
exceptions.add(exception)
}
val scope = CoroutineScope(handler)
scope.launch {
throw RuntimeException("Test exception")
}
advanceUntilIdle()
assertEquals(1, exceptions.size)
}
This confirms your handler is wired correctly before production.
Debugging Production Crashes
When a Kotlin coroutine crashes in production, stack traces in LightTrace show you exactly where. Look for the coroutine dispatcher name in the stack trace—it tells you whether the failure was on the main thread, I/O pool, or a custom dispatcher. See how to read a stack trace for the full breakdown.
If you're seeing gaps in your crash reports—errors your team knows happened but aren't showing up—that's almost always a missing CoroutineExceptionHandler or an exception swallowed in an async() that was never awaited. Use the error-tracking patterns above and error tracking best practices to plug those gaps.
Start tracking errors in minutes
Coroutine exceptions hiding in production? Start tracking them for free on LightTrace—no changes to your Sentry SDK, just point the DSN and you're done.
Structured concurrency is powerful because it prevents orphaned tasks and cascading failures. But it requires you to be intentional about error boundaries. Wire in CoroutineExceptionHandler today, and your team will sleep better knowing crashes are visible, not invisible.