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
suspendfunctions pause without blocking a thread; call them from coroutines or other suspend functions.launchstarts fire-and-forget work returning aJob;asyncreturns aDeferredresult youawait.- Every coroutine belongs to a scope; children must finish before the parent completes, and cancelling a parent cancels its children.
coroutineScopefails together;supervisorScopelets children fail independently.- Cancellation is cooperative and uses
CancellationException— never swallow it. - Test with
runTestand virtual time; avoidGlobalScopeand blocking calls on the wrong dispatcher.
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
- An exception in
launchpropagates to the parent immediately; at the root, it goes to aCoroutineExceptionHandleror the thread's uncaught handler. - An exception in
asyncis thrown fromawait(), but inside a regular scope it also cancels the parent — wrapping onlyawait()in try/catch does not contain it. withTimeoutthrowsTimeoutCancellationException;withTimeoutOrNullreturnsnullinstead.
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
runTestruns a coroutine test with aTestDispatcherand virtual time:delayand timeouts complete instantly while preserving order.StandardTestDispatcherqueues coroutines until the test advances time (advanceUntilIdle(),advanceTimeBy()), making ordering explicit.- Inject dispatchers into classes instead of hard-coding
Dispatchers.IO, so tests can replace them. - Turbine makes testing
Flowemissions concise. - Replace
Dispatchers.Mainin tests withDispatchers.setMain(testDispatcher).
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
- Kotlin — The language
- Kotlin for Android — Coroutines in Android apps
- Concurrency Patterns — Patterns across languages
- Java Virtual Threads — The JVM's other approach to lightweight concurrency
- Concurrency and Parallel Computing — Foundations
- Android Development — Lifecycle-aware scopes