Kotlin Coroutines: Complete Guide to Suspend Functions, Scopes and Flow
A thorough guide to Kotlin's concurrency model — how suspend functions release the thread instead of blocking it, how structured concurrency prevents leaked work, and how Flow handles streams of values over time.
A coroutine is a computation that can suspend and resume without blocking the thread it runs on. That distinction is the whole point: a blocked thread consumes a stack and an OS scheduling slot while doing nothing, whereas a suspended coroutine consumes a small heap object and frees its thread for other work.
The consequence is that tens of thousands of concurrent coroutines are routine, where the same number of threads would exhaust memory. This guide covers the suspension model, structured concurrency, cancellation, error handling, and Flow.
Suspending Without Blocking
The suspend modifier marks a function that may pause. It can only be called from another suspend function or from inside a coroutine builder, which is how the compiler guarantees a suspension point always has somewhere to suspend to.
import kotlinx.coroutines.*
// `suspend` means: this may pause, and resume later
suspend fun fetchUser(id: Int): User {
delay(100) // suspends; does NOT block the thread
return User(id, "Ada")
}
suspend fun fetchOrders(userId: Int): List<Order> {
delay(150)
return listOf(Order(1, userId))
}
// Sequential by default — reads like blocking code, but isn't
suspend fun loadProfile(id: Int): Profile {
val user = fetchUser(id) // ~100ms
val orders = fetchOrders(user.id) // ~150ms
return Profile(user, orders) // total ~250ms
}
delay() suspends the coroutine; Thread.sleep() blocks the underlying thread. Calling the latter inside a coroutine defeats the entire model and can stall every other coroutine sharing that thread.Note that coroutine code is sequential unless you explicitly ask for concurrency. This is deliberate — the common case is correct by default, and parallelism is opt-in.
coroutineScope, launch and async
Every coroutine belongs to a scope, and a scope does not complete until all of its children do. This is structured concurrency, and it is what makes coroutine code leak-free by construction: work cannot outlive the scope that started it.
import kotlinx.coroutines.*
// Run both requests concurrently and await both results
suspend fun loadProfileFast(id: Int): Profile = coroutineScope {
val user = async { fetchUser(id) } // starts immediately
val orders = async { fetchOrders(id) } // starts immediately
// ~150ms total rather than ~250ms
Profile(user.await(), orders.await())
}
// launch returns a Job, not a value — for side effects
suspend fun refreshAll(ids: List<Int>) = coroutineScope {
ids.forEach { id ->
launch { syncUser(id) }
}
// coroutineScope does not return until every launch completes
}
// awaitAll is cleaner for a collection of results
suspend fun loadMany(ids: List<Int>): List<User> = coroutineScope {
ids.map { id -> async { fetchUser(id) } }.awaitAll()
}
| Builder | Returns | Use for |
|---|---|---|
launch | Job | Side effects, no result needed |
async | Deferred<T> | Concurrent work producing a value |
runBlocking | T | main(), tests — blocks the caller |
withContext | T | Switching dispatcher for a block |
runBlocking inside application code on Android or a server — it blocks the calling thread until completion, which is the exact thing coroutines exist to avoid. It belongs in main() and tests only.Choosing Which Threads Run the Work
A dispatcher decides which thread pool a coroutine resumes on. Picking the right one matters because blocking the wrong pool has very different consequences.
import kotlinx.coroutines.*
class UserRepository(private val api: Api, private val dao: Dao) {
// The caller does not need to know which dispatcher is used —
// a suspend function should be safe to call from anywhere.
suspend fun loadUser(id: Int): User = withContext(Dispatchers.IO) {
dao.findById(id) ?: api.fetch(id).also { dao.insert(it) }
}
// CPU-bound work belongs on Default, not IO
suspend fun computeStats(data: List<Int>): Stats =
withContext(Dispatchers.Default) {
Stats(mean = data.average(), max = data.max())
}
}
// Naming a coroutine aids debugging; context elements combine with +
val job = scope.launch(Dispatchers.IO + CoroutineName("sync")) {
syncEverything()
}
| Dispatcher | Backed by | Use for |
|---|---|---|
Dispatchers.Default | CPU-count-sized pool | CPU-bound work: parsing, sorting, maths |
Dispatchers.IO | Large elastic pool | Blocking IO: disk, network, JDBC |
Dispatchers.Main | UI thread | Android/UI updates only |
Dispatchers.Unconfined | Caller's thread | Rarely — advanced cases and tests |
The rule of thumb: a suspend function should be safe to call from any dispatcher. Put the withContext inside the function rather than requiring every caller to remember to wrap it.
Cooperative, Not Forced
Cancellation in coroutines is cooperative. Cancelling a job sets a flag and throws CancellationException at the next suspension point — code that never suspends never notices.
import kotlinx.coroutines.*
// BROKEN: no suspension point, so cancellation is never observed
val bad = scope.launch {
var i = 0
while (i < 1_000_000_000) { i++ } // runs to completion regardless
}
bad.cancel() // has no effect until the loop ends
// FIX 1: check isActive
val good = scope.launch {
var i = 0
while (isActive && i < 1_000_000_000) { i++ }
}
// FIX 2: yield() or any suspending call acts as a checkpoint
val alsoGood = scope.launch {
repeat(1_000_000) {
heavyStep()
yield() // suspension point: cancellation surfaces here
}
}
// Timeouts cancel automatically
val result = withTimeoutOrNull(2_000) { fetchUser(1) } // null on timeout
Cleanup Without Swallowing Cancellation
Because cancellation arrives as an exception, a broad catch will intercept it and break the cancellation chain. Cleanup belongs in finally, and CancellationException must be rethrown if caught.
import kotlinx.coroutines.*
suspend fun process() {
val file = openFile()
try {
file.writeAll(fetchData())
} catch (e: CancellationException) {
throw e // MUST rethrow — never swallow cancellation
} catch (e: IOException) {
log.error("write failed", e)
} finally {
// A cancelled coroutine cannot suspend, so a suspending
// close() here would fail without NonCancellable.
withContext(NonCancellable) { file.closeAndFlush() }
}
}
catch (e: Exception) inside a coroutine silently captures CancellationException, leaving the coroutine running after cancellation. Catch specific types, or rethrow cancellation explicitly.Job vs SupervisorJob
By default a failing child cancels its parent, which cancels every sibling. A SupervisorJob changes this so that children fail independently — the right choice when one failed item should not abort the rest.
import kotlinx.coroutines.*
// Default Job: one failure cancels all siblings
suspend fun allOrNothing() = coroutineScope {
launch { stepOne() }
launch { error("boom") } // cancels stepOne too
}
// SupervisorJob: failures are isolated
suspend fun bestEffort(ids: List<Int>) = supervisorScope {
ids.forEach { id ->
launch {
try {
syncUser(id)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log.warn("sync failed for $id", e)
}
}
}
}
// A scope with a handler for otherwise-uncaught failures
private val handler = CoroutineExceptionHandler { _, e ->
log.error("uncaught in scope", e)
}
val appScope = CoroutineScope(
SupervisorJob() + Dispatchers.Default + handler
)
CoroutineExceptionHandler only applies to launch. An exception inside async is stored in the Deferred and rethrown at await(), so it must be caught around the await call.Cold Asynchronous Streams
A suspend function returns one value. A Flow emits many over time, and it is cold — nothing runs until a terminal operator such as collect subscribes.
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.*
fun pagedUsers(): Flow<User> = flow {
var page = 0
while (true) {
val batch = api.fetchPage(page++) // suspending call
if (batch.isEmpty()) break
batch.forEach { emit(it) }
}
}
suspend fun report() {
pagedUsers()
.filter { it.isActive }
.map { it.email }
.take(100)
.flowOn(Dispatchers.IO) // upstream runs on IO
.catch { e -> log.error("stream failed", e) }
.onCompletion { log.info("done") }
.collect { email -> send(email) } // terminal: starts the flow
}
// StateFlow holds a current value — hot, for observable state
class ViewModel {
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
fun load(scope: CoroutineScope) = scope.launch {
_state.value = try {
UiState.Ready(fetchUser(1))
} catch (e: Exception) {
UiState.Error(e.message ?: "unknown")
}
}
}
Note flowOn affects everything upstream of it, while the collect block runs on the collector's dispatcher. That asymmetry is intentional and is what lets a flow produce on IO and consume on the main thread.
| Type | Hot/cold | Use for |
|---|---|---|
Flow | Cold | A stream produced per collector |
StateFlow | Hot | Observable current state, always has a value |
SharedFlow | Hot | Events broadcast to multiple collectors |
Channel | Hot | Point-to-point handoff between coroutines |
• Coroutine code is sequential by default — use async for concurrency.
• Put withContext inside a suspend function, not at every call site.
• Cancellation is cooperative: check isActive in loops without suspension points.
• Never swallow CancellationException in a broad catch.
• supervisorScope isolates child failures; coroutineScope propagates them.
• Flow is cold and starts only at a terminal operator; StateFlow is hot.
Summary
Coroutines make asynchronous code read sequentially while keeping threads free, and structured concurrency ensures that work cannot outlive the scope that launched it — the two properties that together eliminate most concurrency leaks.
Use coroutineScope with async for parallel work, supervisorScope when failures should be isolated, withContext to place work on the right dispatcher, and Flow for streams of values rather than single results.
The two recurring mistakes are worth committing to memory: blocking inside a coroutine, and swallowing CancellationException. Avoid both and the model behaves exactly as it reads.