Kotlin Coroutines and Structured Concurrency

Kotlin coroutines let you write asynchronous code that reads like ordinary sequential code: a suspend function can wait for a network call without blocking a thread, and thousands of coroutines can run on a handful of threads. The more important idea, though, is structured concurrency: every coroutine runs inside a scope, a parent waits for its children, and cancellation and failures flow through that tree predictably. Leaked background work, orphaned requests that keep running after a screen closes, and exceptions that silently disappear all come from stepping outside that structure.

This page covers the building blocks, how cancellation and exceptions propagate, and how to test coroutine code deterministically.

TL;DR

Quick Example

Load a dashboard from three services concurrently. If a required call fails, the others are canceled; the optional call has a time budget:

Testing it with virtual time — the slow fake does not slow the test:

Core Concepts

Builders, Jobs, and Scopes

Structured Concurrency

Coroutines form a tree. A scope does not complete until all of its children complete; cancelling a scope cancels every descendant; a failing child (in a regular scope) cancels its siblings and the parent. The practical effect: when loadDashboard returns or throws, no work it started is still running. Lifecycle-bound scopes — Android's viewModelScope and lifecycleScope, or a server framework's request scope — cancel work automatically when the screen or request goes away.

coroutineScope Versus supervisorScope

Dispatchers

Switch with withContext(Dispatchers.IO) { ... }. Suspending libraries (Ktor client, Room, coroutine-aware database drivers) do not need Dispatchers.IO.

Cancellation

Cancellation is cooperative: a coroutine is canceled at its next suspension point, or when it checks isActive or calls ensureActive(). Cancellation is delivered as a CancellationException, which must propagate. Long CPU loops should check for cancellation periodically. Cleanup that must run even when canceled goes in finally, with suspending cleanup wrapped in withContext(NonCancellable).

Exceptions

Flow

Flow is a cold asynchronous stream built on coroutines: nothing runs until it is collected, and collection respects the collector's scope and cancellation. Operators such as map, filter, debounce, and flatMapLatest compose streams; flowOn changes the upstream dispatcher. StateFlow holds a current value for UI state, and SharedFlow broadcasts events to multiple collectors. Collect flows in lifecycle-aware scopes so collection stops when no one is watching.

Testing

Best Practices

Never Use GlobalScope for Application Work

Work in GlobalScope has no parent, is never canceled automatically, and leaks when callers go away. Use a scope that matches the work's lifetime.

Make Suspend Functions Main-Safe

A suspend function should be safe to call from any dispatcher, switching to IO or Default internally when it does blocking or heavy work.

Rethrow CancellationException

When catching broad exceptions in coroutines, rethrow CancellationException (or catch narrower types) so cancellation keeps working.

Bound Concurrency

Launching one coroutine per item is easy; thousands of simultaneous requests are not. Use limitedParallelism, a Semaphore, or chunking to protect downstream services.

Common Mistakes

Swallowing Cancellation

Blocking Calls on the Wrong Dispatcher

Fire-and-Forget Outside the Request

FAQ

What is structured concurrency in Kotlin?

A model in which every coroutine runs inside a scope that forms a parent-child tree. Parents wait for children, cancellation propagates down the tree, and failures propagate up, so no coroutine is accidentally left running.

What is the difference between launch and async?

launch starts a coroutine for its side effects and returns a Job. async starts a coroutine that produces a result, returned through Deferred.await(). Both follow the same structured-concurrency rules.

When should I use supervisorScope?

When child coroutines are independent and one failing should not cancel the others — for example, processing a batch of items or updating several independent widgets.

Why should I not catch CancellationException?

Because cancellation is implemented by throwing it. Catching and ignoring it lets a canceled coroutine keep running, breaking timeouts, lifecycle cleanup, and structured concurrency.

How do I test code that uses delay or timeouts?

Use runTest from kotlinx-coroutines-test. It uses virtual time, so delays and timeouts complete instantly while keeping their relative order, and you can advance time explicitly with a StandardTestDispatcher.

Related Topics

References