Coroutine Context, Cancellation and Exceptions

This section documents Kotlin 2.4.x on the JVM, as published at kotlinlang.org, which is the reference these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against kotlinlang.org before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Building on Coroutines Basics, this page covers what actually determines where a coroutine runs, how it is cancelled cleanly, and how it handles failure — three concerns Kotlin bundles together as a coroutine’s CoroutineContext.

CoroutineContext

Every coroutine carries a CoroutineContext — a set-like collection of elements (a Job, a Dispatcher, a name, an exception handler) that together describe how and where it runs. Context elements combine with +:

val context = Dispatchers.IO + CoroutineName("data-loader")
scope.launch(context) { /* ... */ }

Dispatchers

A Dispatcher decides which thread(s) a coroutine’s code actually runs on — suspending does not, by itself, guarantee any particular thread:

Dispatcher Use

Dispatchers.Default

CPU-bound work (sorting, parsing, computation) — backed by a thread pool sized to the number of CPU cores.

Dispatchers.IO

blocking I/O (network calls, file access, JDBC) — a larger, elastic thread pool designed for many simultaneously-blocked threads.

Dispatchers.Main

the UI thread on a platform that has one (Android’s main thread) — only available when the corresponding platform artifact is on the classpath.

suspend fun loadAndRender() {
    val data = withContext(Dispatchers.IO) {   // switches to the IO dispatcher for this block only...
        fetchFromNetwork()
    }
    withContext(Dispatchers.Main) {             // ...then back to Main to update the UI
        render(data)
    }
}

withContext suspends the calling coroutine, runs the block on the requested dispatcher, and resumes back on the original one automatically once the block completes — there is no manual "post back to the UI thread" callback to write.

Cancellation and Timeouts

Cancellation in coroutines is cooperative: a coroutine must actually check for cancellation (which every suspending function in kotlinx.coroutines, like delay, does automatically) to actually stop. withTimeout cancels its block automatically if it runs too long, throwing TimeoutCancellationException; withTimeoutOrNull does the same but returns null instead of throwing:

suspend fun fetchWithDeadline(): User? =
    withTimeoutOrNull(2_000) {         // milliseconds
        fetchUser(1)                     // cancelled, and this call returns null, if it takes >2s
    }

A long-running, non-suspending computation (a tight loop with no delay/suspension point inside it) will not respond to cancellation on its own — ensureActive() or isActive must be checked explicitly inside such a loop for it to cooperate.

SupervisorJob

By default (structured concurrency, see Coroutines Basics), one child’s failure cancels every sibling and the parent. A SupervisorJob changes that: a child’s failure is isolated to that child, leaving its siblings running — the shape needed for, say, a screen with several independent widgets that should not all crash because one of them failed to load:

val supervisorScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

supervisorScope.launch { failingWidget() }   // this one fails...
supervisorScope.launch { healthyWidget() }    // ...but this one is unaffected

CoroutineExceptionHandler

An uncaught exception from a launch coroutine (not async — a Deferred’s exception surfaces at `.await() instead) is, by default, propagated up and can crash the application the same way an uncaught exception on any thread would. A CoroutineExceptionHandler, installed on the top-level scope, intercepts it instead:

val handler = CoroutineExceptionHandler { _, exception ->
    log.error("unhandled coroutine failure", exception)
}

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler)
scope.launch { throw IllegalStateException("boom") }   // caught by "handler" instead of crashing

CoroutineExceptionHandler only has an effect on a root coroutine (one launched directly in a scope, not nested inside another coroutine) and is commonly paired with SupervisorJob for exactly the "many independent tasks, log and continue" pattern above.

See Also

  • Coroutines Basics — launch/async/CoroutineScope and default structured-concurrency propagation.

  • Flows — dispatchers and cancellation in the context of a stream of values rather than a single result.

  • Kotlin for Android — Dispatchers.Main in an actual Android app.