Interview QuestionPractical QuestionFollow-up Questions

Coroutine Exception Handling: launch vs async, SupervisorJob, and CoroutineExceptionHandler

skydovesJaewoong Eum (skydoves)||10 min read

Coroutine Exception Handling: launch vs async, SupervisorJob, and CoroutineExceptionHandler

Kotlin Coroutines use structured concurrency to propagate exceptions through the Job hierarchy. When a child coroutine fails, the exception travels upward to the parent, which cancels all remaining siblings and fails itself. This behavior keeps concurrent operations consistent, but it also means that exception handling in coroutines follows different rules than regular try/catch. How launch and async handle exceptions differently, when CoroutineExceptionHandler actually fires, and why runCatching is dangerous inside coroutines are questions that test whether a developer understands the exception propagation contract rather than just the surface API. By the end of this lesson, you will be able to:

  • Explain how exceptions propagate from child to parent through childCancelled() and cancelParent().
  • Describe the difference between how launch and async handle uncaught exceptions at the source code level.
  • Identify when CoroutineExceptionHandler fires and when it does not.
  • Explain how SupervisorJob blocks exception propagation and what happens to unhandled exceptions in its children.
  • Identify why runCatching silently swallows CancellationException and how to avoid this trap.

The exception propagation path

When a coroutine throws an exception, the runtime follows a specific chain of method calls to decide what happens. The chain starts in JobSupport.finalizeFinishingState(), which aggregates exceptions from all children, selects a root cause, and then calls cancelParent():

if (finalException != null) {
    val handled = cancelParent(finalException) || handleJobException(finalException)
    if (handled) (finalState as CompletedExceptionally).makeHandled()
}

Two things can handle the exception. First, cancelParent() attempts to propagate the exception to the parent job. If the parent handles it (by cancelling itself), the exception is considered handled. Second, if the parent does not handle it, handleJobException() is called as a fallback.

cancelParent() calls the parent's childCancelled() method, which is the decision point for exception propagation:

public open fun childCancelled(cause: Throwable): Boolean {
    if (cause is CancellationException) return true
    return cancelImpl(cause) && handlesException
}

CancellationException is absorbed silently, returning true without cancelling the parent. Any other exception calls cancelImpl(), which transitions the parent to the cancelling state and propagates the failure to all its children. This is why a single failed child in a coroutineScope cancels all siblings.

launch vs async: Two different contracts

The launch and async builders create different coroutine types, and those types override handleJobException() differently. This override determines what happens when an exception is not handled by the parent.

launch creates a StandaloneCoroutine, which overrides handleJobException() to call the CoroutineExceptionHandler and return true:

private open class StandaloneCoroutine(
    parentContext: CoroutineContext,
    active: Boolean
) : AbstractCoroutine<Unit>(parentContext, initParentJob = true, active = active) {
    override fun handleJobException(exception: Throwable): Boolean {
        handleCoroutineException(context, exception)
        return true
    }
}

When a launch coroutine throws and the parent does not handle the exception, handleCoroutineException() looks for a CoroutineExceptionHandler in the coroutine context. If one exists, it calls handleException(). If none exists, the exception goes to the thread's uncaught exception handler, which on Android crashes the app.

This interview continues for subscribers

Subscribe to Dove Letter for full access to exclusive interviews about Android and Kotlin development.

Become a Sponsor