
Kotlin Coroutines Crash Debugging: Fix Async Crashes
If you ship Android apps that lean on Kotlin coroutines, you have almost certainly opened a crash report whose stack trace dissolves into nothing. A background job died, the app crashed, and your crash reporter never saw the exception. Kotlin coroutines crash debugging is its own discipline because coroutines do not fail the way ordinary Java code does: an exception raised inside a launch block does not propagate to the thread that started it, async hides failures inside a Deferred, and a CancellationException that escapes your code looks like a crash even though it is normal control flow. This guide walks through the tools that make these failures visible — CoroutineExceptionHandler, structured concurrency, supervisorScope versus coroutineScope, and the Main dispatcher pitfalls — so you can find and fix async crashes before your users do.
Why coroutine exceptions disappear from your reports
Most Android crash reporters hook Thread.setDefaultUncaughtExceptionHandler, which fires when a throwable escapes a thread's top-level frame. Coroutines break that assumption. When a coroutine throws, the exception does not travel up the call stack of the thread that launched it. Instead, the coroutine machinery routes the failure through the coroutine's Job and its parent scope, and it surfaces through a CoroutineExceptionHandler if one is installed — otherwise it reaches the default handler and terminates the process in ways that are hard to attribute. As the official Kotlin documentation explains, coroutine builders split into two families: launch propagates exceptions automatically, while async exposes them to you and expects you to consume them via await(). Kotlin exception handling documentation If your crash reporter only listens to the uncaught-thread handler, an entire class of coroutine failures will never show up — which is why coroutine crash debugging starts with wiring exceptions back into your observability stack.
CoroutineExceptionHandler: your first line of defense
CoroutineExceptionHandler is a CoroutineContext element that receives any uncaught exception from a coroutine launched in a scope. Install it on the scope, and every root coroutine that fails without an internal handler delivers its throwable to your callback:
val handler = CoroutineExceptionHandler { _, throwable ->
BugsPulse.report(throwable)
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + handler)
scope.launch {
throw IllegalStateException("background sync failed")
}Two properties trip people up. First, the handler only fires for exceptions that are truly unhandled — if a child coroutine catches its own throwable, the handler never sees it, which is usually what you want. Second, CoroutineExceptionHandler is a terminal hook, not a rescue: the coroutine is still cancelled, and if it was a root coroutine on a non-supervisor scope, its parent scope is cancelled too. The handler runs on the thread associated with the failing coroutine's dispatcher, so a failure on Dispatchers.IO invokes your callback on that same thread. CoroutineExceptionHandler API reference
A common mistake is installing the handler on a child coroutine's context instead of the scope, or expecting it to catch async failures. It will not: async defers its exception to await(), which is a separate problem we will cover shortly.
Structured concurrency: supervisorScope vs coroutineScope
Structured concurrency decides what happens to a coroutine's siblings when it fails, and the choice between supervisorScope and coroutineScope is the most consequential decision you make for crash isolation. In a plain coroutineScope, a failure in any child cancels the entire scope and propagates the exception to the parent — a single broken request can tear down a whole screen's worth of work:
coroutineScope {
launch { loadFeed() } // this fails
launch { loadProfile() } // this gets cancelled
}supervisorScope changes that contract: a child's failure is contained and its siblings keep running. The exception still propagates up to the parent scope (and therefore to your CoroutineExceptionHandler), but sibling jobs are not cancelled:
supervisorScope {
launch { loadFeed() } // fails and reports to the handler
launch { loadProfile() } // keeps running
}This is why view-model scopes built on SupervisorJob let one failed screen operation avoid killing the rest of the UI. The Kotlin documentation frames structured concurrency as the reason exceptions are not lost: a parent always knows when a child fails, and it either handles, cancels, or propagates that failure. Kotlin coroutines basics
Uncaught exceptions in launch vs async
launch and async treat failure differently, and this difference is the source of most "disappearing exception" bugs. When a launch coroutine throws and nothing catches it, the exception is treated as unhandled immediately: it is dispatched to the scope's CoroutineExceptionHandler and cancels the parent job. When an async coroutine throws, the exception is captured inside the returned Deferred and is not reported until someone calls await():
val scope = CoroutineScope(SupervisorJob() + handler)
scope.launch { throw RuntimeException("reported right away") }
val deferred = scope.async { throw RuntimeException("silent until awaited") }
// ... nothing crashes yet ...
val result = deferred.await() // now it throwsThe dangerous case is the async result you never await. Within structured concurrency a failed async child still cancels its parent, so the failure eventually surfaces — but if you fire an async from an unstructured scope such as GlobalScope and never await it, the exception can vanish entirely: no crash, no report, and no way to know a job failed. The rule that prevents this whole class of bugs is simple: always await() your async results, and prefer launch for fire-and-forget work. Exception handling reference
async/await: the deferred exception trap
The deferred exception trap gets worse when you mix async with runCatching or with try/catch around the wrong call. Because the failure is raised at the await() site and not the async site, a try block wrapped around the async { } builder catches nothing:
try {
val d = async { fetchUser() } // fetchUser throws inside
} catch (e: Exception) {
// never reached — the exception lives in the Deferred, not here
}Wrap the await() call instead, or let the exception propagate to a supervisorScope handler that reports it. When you use async to run two requests concurrently and combine their results, remember that coroutineScope cancels the second request if the first fails, while supervisorScope lets the second finish and report independently. Matching the structure to the semantics you actually want is half of coroutine crash debugging.
CancellationException: the exception that is not a crash
Not every throwable in a coroutine stack trace is a bug. CancellationException is how Kotlin coroutines perform cooperative cancellation, and it is thrown routinely when a scope is cancelled, a timeout fires, or a parent job fails. The first rule of handling it is: never swallow it. Catching Exception (or worse, Throwable) and returning normally inside a suspend function hides the cancellation, so the coroutine keeps running after it was asked to stop — producing the zombie-job symptoms, and later a real crash, that are notoriously hard to reproduce:
suspend fun load() {
try {
withTimeout(5000) { fetchFromNetwork() }
} catch (e: CancellationException) {
throw e // rethrow — never swallow cancellation
} catch (e: Exception) {
BugsPulse.report(e)
}
}If you must run cleanup on cancellation, do it in a finally block and keep it non-suspending, because a cancelled coroutine rejects suspension calls inside its finally. The Kotlin documentation on cancellation and timeouts spells out exactly which operations remain available after cancellation. Cancellation and timeouts
Main dispatcher crashes: the thread you cannot block
Dispatchers.Main is backed by the Android main thread, which is also the UI thread. Blocking it — with Thread.sleep, a synchronous network call, a heavy runBlocking computation, or a database query run on the wrong dispatcher — is how you get NetworkOnMainThreadException and Application Not Responding (ANR) terminations that look, to your users, exactly like a crash. Coroutines make this easy to get wrong precisely because they look synchronous: a suspend function with a blocking body still blocks whatever thread it runs on.
// Wrong: suspends, but the blocking body still runs on Main
scope.launch(Dispatchers.Main) {
val data = repository.loadBlocking() // blocks the UI thread
}Fix this by moving blocking work to Dispatchers.IO or Dispatchers.Default and keeping only the final UI update on Main. Android's main-safety guidance — don't block the main thread — is a hard requirement for coroutine code, not a suggestion. Android coroutines on the main thread Use Dispatchers.Main.immediate when you want synchronous dispatch if you are already on the main thread. Android threading and performance
GlobalScope and runBlocking: the two antipatterns
Two constructs cause an outsized share of coroutine crashes. GlobalScope launches coroutines outside any parent job: they are not tied to a lifecycle, so they keep running after a screen or activity is destroyed, and their exceptions go to the default handler — usually meaning a process crash with no app-level context attached. runBlocking is the other trap: it blocks the calling thread until the coroutine finishes, so calling it on Main freezes the UI, and its exceptions propagate synchronously and crash the caller rather than flowing through your coroutine handler:
fun onButtonClick() {
runBlocking { networkCall() } // freezes the main thread
}Prefer injecting a scoped CoroutineScope with a SupervisorJob plus a handler, and confine runBlocking to tests or to the rare main() entry point. For a deeper look at the race conditions that arise when coroutine and thread code interact, see our mobile thread safety guide.
Surfacing coroutine crashes in your crash reporter
A robust coroutine crash setup has three layers. First, install a CoroutineExceptionHandler on every scope you own and forward its throwable to your crash reporter with breadcrumbs for the coroutine context. Second, keep Thread.setDefaultUncaughtExceptionHandler in place as a backstop for native and thread-level crashes — but understand it will not see coroutine exceptions handled by a scope, which is why the first layer matters. Third, attach structured metadata: the dispatcher, the scope name, and the operation the coroutine was performing, so that a silent async failure becomes a searchable, groupable event rather than a mystery.
BugsPulse makes this practical by letting you group coroutine failures by exception type and stack, attach breadcrumbs from the suspend call chain, and alert on sudden spikes — so a leaked GlobalScope job that starts throwing after a server-side change shows up within minutes instead of after a thousand one-star reviews. When you standardize on supervisorScope for independent work, coroutineScope for atomic multi-step work, and a shared handler that reports everything, you turn a class of invisible failures into ordinary, fixable incidents.
Fix async crashes for good
Coroutine crash debugging is less about chasing individual stack traces and more about making the failure model explicit. Install a CoroutineExceptionHandler, choose supervisorScope or coroutineScope deliberately, always await() your async results, rethrow CancellationException, and never block the Main thread. If you are coming from the iOS side, the same patterns apply to Swift's structured concurrency, which we cover in our Swift Concurrency crash debugging guide.
Coroutine failures are invisible by default — but they do not have to be. See how BugsPulse surfaces async crashes, groups them, and alerts your team in real time at bugspulse.com.
Ready to catch every coroutine crash before your users do? Start your free BugsPulse account and see your async failures the moment they happen.