Available for work
Ankit Ranjan
Back to Deep Dives

Kotlin Coroutines & Flow — A Million Threads That Aren't Threads

How Kotlin makes asynchronous code read like ordinary code. A deep dive into suspend functions and the state machine behind them, dispatchers, structured concurrency, cancellation, and the cold and hot streams of Flow.

June 4, 2026 10 topics 7 quiz questions
Share:
1

The callback pyramid of doom

Almost every interesting program has to wait. It waits for a network response, a database query, a file to load, a user to tap a button. The hard question of concurrent programming is: what does the rest of your program do while it waits?

The naive answer — just block and wait — is a disaster on a single thread. If your app's main thread blocks for a one-second network call, the UI freezes for a full second. So for decades the standard solution was the callback: "go do this slow thing, and call me back when you're done." It works, but it does not compose. Chain a few async steps together and you get the infamous pyramid of doom.

// Callback hell: each step nests inside the last
fetchUser(id) { user ->
    fetchProfile(user) { profile ->
        fetchPhoto(profile) { photo ->
            updateUI(user, profile, photo)
        } // error handling? cancellation? good luck
    }
}
The logic drifts ever further to the right. Error handling becomes a nightmare — each callback needs its own. Cancelling the whole chain partway through is nearly impossible. The code no longer reads in the order it executes.

Other languages attacked this with Future/Promise chains (.then().then().catch()) and later with async/await. Kotlin's answer is coroutines, and its central promise is simple but radical: write asynchronous code that looks exactly like ordinary sequential code.

// The same logic with coroutines — flat, sequential, cancellable
suspend fun loadScreen(id: String) {
    val user = fetchUser(id)
    val profile = fetchProfile(user)
    val photo = fetchPhoto(profile)
    updateUI(user, profile, photo)
}
No nesting. No callbacks. Errors propagate with ordinary try/catch. And, as we will see, the whole thing can be cancelled by cancelling one parent. The thread is never blocked while waiting. This is the payoff. The rest of this episode explains how it works.

2

What a coroutine actually is

The word coroutine is older than you might think. It was coined by Melvin Conway in 1958 — the same Conway of "Conway's Law" — to describe routines that cooperate by voluntarily handing control back and forth. Coroutines predate threads as a concurrency idea by decades; they fell out of fashion, then came roaring back.

The key distinction is between suspending and blocking.

• A blocked thread is stuck. It holds its expensive ~1 MB of memory and does nothing useful until the thing it waits for completes. The operating system cannot reuse it.
• A suspended coroutine has stepped aside. It saves where it was, releases the thread entirely, and lets that thread go run other work. When its data is ready, it resumes — possibly on a different thread.

That difference is everything. Because a suspended coroutine costs almost nothing — just a small object on the heap — you can have an absurd number of them.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // Launch 100,000 coroutines. Try this with threads and your machine dies.
    repeat(100_000) {
        launch {
            delay(1000L)   // suspends — does NOT block a thread
            print(".")
        }
    }
}
This program runs fine, finishing in about a second, on a handful of threads. The equivalent with 100,000 real threads would exhaust memory long before it started. Coroutines are sometimes called "lightweight threads," but that undersells it — they are a fundamentally cheaper unit of work.

One more piece of history worth knowing: coroutines are not part of the Kotlin language in the way if or class are. The language provides exactly one primitive — the suspend keyword — and everything else (launch, async, Flow, dispatchers) lives in a separate library, kotlinx.coroutines. This was a deliberate choice by the team, led by Roman Elizarov: keep the language small, put the machinery in a library that can evolve independently.

3

suspend and the state machine

How can a function pause in the middle and resume later? There is no magic — just a clever compiler trick that is worth understanding, because it demystifies the whole model.

When you mark a function suspend, the Kotlin compiler rewrites it using Continuation-Passing Style (CPS). It secretly adds one hidden parameter — a Continuation — which is essentially a callback representing "the rest of the program." So this:

suspend fun loadUser(id: String): User
is compiled to something closer to this under the hood:

fun loadUser(id: String, cont: Continuation<User>): Any?
Then, at every point where the function might suspend, the compiler slices it into pieces and stitches them back together as a state machine. Each suspension point becomes a labelled state. When the function resumes, it jumps back to the right label with its local variables restored.

One suspend function → a resumable state machinestate 0: startfetchUser()state 1fetchProfile()state 2fetchPhoto()resumeresumeAt each suspension point the coroutine SUSPENDS:saves its state + locals into the Continuation, releases the thread, and returns COROUTINE_SUSPENDED.When the result is ready, the Continuation is called →the machine jumps to the next state and continues.

This is why a single suspend function can "pause" without a thread sitting idle: at each suspension point it literally returns control to its caller (returning a special COROUTINE_SUSPENDED marker), freeing the thread. When the awaited value arrives, the saved Continuation is invoked and the state machine advances. You write straight-line code; the compiler builds the callback machinery for you.

A crucial consequence: you can only call a suspend function from another suspend function, or from inside a coroutine builder. Suspension is "coloured" — it propagates up the call stack until it reaches something that knows how to launch a coroutine.

4

Coroutine builders: launch, async, runBlocking

You cannot call a suspend function from ordinary code directly — you need a coroutine builder to bridge the normal world and the suspending world. There are three you will meet constantly.

launch — fire-and-forget. It starts a coroutine that returns no result, and hands you back a Job you can use to cancel or wait on it.

val job = scope.launch {
    saveToDatabase()   // we don't need a return value
}
job.cancel()           // ...but we can cancel it
async — when you want a result. It returns a Deferred<T> (a coroutine-aware "promise"). Call await() to suspend until the value is ready. This is also how you run things concurrently.

suspend fun loadDashboard() = coroutineScope {
    // Both requests start immediately, running concurrently
    val user = async { fetchUser() }
    val feed = async { fetchFeed() }
    // Suspend until BOTH are done
    render(user.await(), feed.await())
}
runBlocking — the bridge of last resort. It actually blocks the current thread until its coroutine finishes. You use it in main() functions and tests — places where you genuinely want to stop and wait — but almost never in production app code, because blocking is the very thing coroutines exist to avoid.

Three ways into the coroutine worldlaunchreturns Jobfire-and-forgetno result valuedoes not blockasyncreturns Deferred<T>produces a result.await() for valuefor concurrencyrunBlockingreturns TBLOCKS the threadmain() & tests onlyavoid in app code

The sequential-by-default gotcha. This trips up nearly everyone. If you simply await() right after each async, or just call two suspend functions in a row, they run one after another. Concurrency only happens when you start both async blocks before awaiting either. suspend means "can pause," not "runs in parallel."

5

Dispatchers: which thread runs the work

Coroutines suspend and resume — but when they are actively running, they still need a real thread. A dispatcher decides which thread (or thread pool) that is. It is part of the CoroutineContext, the bundle of metadata every coroutine carries.

The standard dispatchersDispatchers.DefaultCPU-intensive workpool sized to CPU cores · sorting, parsing, mathDispatchers.IOblocking I/Olarge elastic pool · network, disk, databaseDispatchers.MainUI threadsingle thread · update widgets, touch handlersDispatchers.Unconfinedadvanced / rarely usedresumes on whatever thread called it · easy to misuse

The everyday tool for moving work between dispatchers is withContext. It suspends the current coroutine, runs its block on the requested dispatcher, and resumes you with the result — no callback, no thread juggling.

suspend fun loadImage(url: String): Bitmap {
    // Suspend here, do the blocking download on the IO pool
    val bytes = withContext(Dispatchers.IO) {
        downloadBytes(url)
    }
    // Heavy decoding belongs on the CPU pool
    return withContext(Dispatchers.Default) {
        decodeBitmap(bytes)
    }
    // The caller (often on Main) is never blocked
}
This is the pattern that keeps UIs smooth: the main thread launches the coroutine, the work hops to IO and Default as needed, and the final result lands back on Main to update the screen — all written as plain top-to-bottom code.

6

Structured concurrency

This is Kotlin's signature contribution to the field, and the idea Roman Elizarov championed in an influential 2018 essay. The principle: a coroutine cannot outlive its scope. Every coroutine is launched inside a CoroutineScope and becomes a child of that scope's Job, forming a tree.

Structured concurrency: a tree of coroutinesCoroutineScopechild: fetchUserchild: fetchFeedchild: fetchAdsgrandchildgrandchildCancel the scope → the whole subtree is cancelled. The scope waits for all children before completing.

Two guarantees fall out of this tree, and they eliminate whole classes of bugs:

Cancel the parent, cancel the children. When a user leaves a screen and its scope is cancelled, every coroutine launched there — and their children — is cancelled automatically. No orphaned background task crashing on a dead screen.
The parent waits for its children. A scope does not complete until all coroutines inside it finish. No silent leaks of work you forgot about.

The everyday tool is coroutineScope { }, which creates a scope that only returns once everything inside it is done. Its sibling supervisorScope { } changes one rule: in a plain scope, if one child fails the others are cancelled; in a supervisor scope, children fail independently. You pick based on whether the tasks are "all or nothing" or genuinely independent.

On Android, you rarely create scopes by hand — the framework provides lifecycle-aware ones like viewModelScope and lifecycleScope that are cancelled automatically when the ViewModel is cleared or the screen is destroyed. Structured concurrency is what makes that automatic cleanup possible.

7

Cancellation is cooperative

Cancellation in coroutines is cooperative, not forceful. Kotlin will not violently kill a coroutine mid-instruction — that would risk leaving data in a broken state. Instead, cancellation sets a flag, and the coroutine is expected to notice and stop.

Every suspending function in kotlinx.coroutines checks for cancellation. So a coroutine that regularly suspends (with delay, withContext, network calls, etc.) cancels promptly and cleanly. The mechanism is an exception: cancellation throws a special CancellationException that unwinds the coroutine.

val job = scope.launch {
    repeat(1000) { i ->
        delay(100)        // a suspension point — checks for cancellation
        println("working $i")
    }
}
delay(350)
job.cancel()              // after ~3 iterations, the next delay() throws
The famous trap. A coroutine doing tight CPU work that never suspends cannot be cancelled — there is no point at which it checks the flag. It will run to completion regardless of cancel().

// BAD: this loop ignores cancellation entirely
launch {
    var i = 0
    while (i < 1_000_000) { heavyCompute(i++) }  // never suspends
}

// GOOD: give it a chance to notice
launch {
    var i = 0
    while (i < 1_000_000 && isActive) {   // check the flag
        heavyCompute(i++)
    }
    // or call yield() / ensureActive() periodically
}
Two more rules that catch people out. First: never swallow a CancellationException in a blanket catch (e: Exception) — if you do, you break cancellation, because that exception is how cancellation travels. Rethrow it. Second: if you must run cleanup that itself suspends (closing a connection, say) after cancellation, wrap it in withContext(NonCancellable) so the cleanup is allowed to finish.

8

Flow — streams of values over time

A suspend function produces one value, eventually. But plenty of problems involve a sequence of values arriving over time: search results as the user types, rows streaming from a database, price ticks, location updates. Kotlin's answer is Flow — an asynchronous stream built on coroutines.

fun searchResults(query: String): Flow<Result> = flow {
    val pages = api.search(query)        // can suspend
    for (page in pages) {
        emit(page)                        // push each value downstream
        delay(50)
    }
}

// Consume it — collect is itself a suspend function
searchResults("kotlin").collect { result ->
    display(result)
}
The defining property of a basic Flow is that it is cold. The code inside the flow { } builder does not run when you create the flow — it runs only when something calls a terminal operator like collect, and it runs again, from scratch, for every collector. A cold flow is a recipe, not a running process.

It helps to place Flow among its relatives:

Sequence — lazy but synchronous: it cannot suspend, so it is for in-memory computation, not waiting on I/O.
Flow — lazy and asynchronous: it can suspend, and it is cold by default.
Channel — a hot pipe between coroutines, like a queue; each value is received by exactly one consumer.

Flow carries a rich operator library — map, filter, transform, debounce, combine, flatMapLatest — that will feel familiar if you have used RxJava, but built natively on suspension and structured concurrency. Operators are themselves cold: chaining them just builds the recipe; nothing runs until collection.

searchResults(query)
    .debounce(300)              // wait for typing to settle
    .filter { it.score > 0.5 }
    .map { it.title }
    .flowOn(Dispatchers.IO)     // upstream runs on IO
    .collect { showSuggestion(it) }

9

Hot flows: StateFlow and SharedFlow

Cold flows are perfect for "do this work when asked." But UIs often need the opposite: a value that exists now and that many observers share — the current screen state, a stream of one-off events. For this, Kotlin provides hot flows.

Cold vs hotCold (flow { })Starts producing only when collectedEach collector gets its own runcollector Acollector B→ independent streamsLike a recipe: re-run per requestHot (StateFlow / SharedFlow)Emits whether or not anyone listensAll collectors share the same streamsourcecollector Acollector BLike a radio broadcast: one source, many tuned in

StateFlow is a hot flow that always holds exactly one current value and emits updates when it changes. It is the modern, coroutine-native replacement for Android's older LiveData, and the backbone of state management in a Jetpack Compose app.

class CounterViewModel {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count.asStateFlow()   // read-only to the UI

    fun increment() { _count.value++ }
}

// In the UI: always has a value, re-renders on change
viewModel.count.collect { n -> render(n) }
SharedFlow is the more general hot flow, useful for one-off events (a navigation command, a "show snackbar" signal) that should be delivered to whoever is listening but not retained as state. The bridge between cold and hot is stateIn / shareIn, which convert a cold upstream flow into a hot one tied to a scope — the standard pattern for exposing a repository's cold database flow as observable UI state.

The mental model that ties it all together: a cold flow is a recipe you run on demand; a hot flow is a live broadcast that exists whether or not anyone is tuned in. Coroutines gave Kotlin a way to pause; Flow gives it a way to stream; hot flows give it a way to share.

10

AI prompts — coroutines and Flow

Use these prompts with an AI assistant to go deeper on the ideas in this episode.

Prompt 1 — Callbacks to coroutines:

Rewrite this callback-based async code using Kotlin coroutines:
[paste code]

Show where each call suspends, mark whether the steps run
sequentially or concurrently, and use structured concurrency.

Prompt 2 — Choosing a dispatcher:
For each operation below, tell me which dispatcher it belongs on
(Default, IO, or Main) and why:
[list your operations: network call, JSON parse, UI update, image decode...]
Then show the withContext calls.

Prompt 3 — Cancellation review:
Review this coroutine code for cancellation correctness:
[paste code]

Flag any tight loops that never suspend, any catch blocks that
swallow CancellationException, and any cleanup that should use
NonCancellable.

Prompt 4 — Cold vs hot flow design:
I'm modelling [screen state / one-off events / a database stream].
Should this be a cold Flow, a StateFlow, or a SharedFlow?

Explain the trade-offs and show how to expose it from a ViewModel,
including stateIn/shareIn if a cold source is involved.

Coroutines & Flow — Quiz

7 questions

Seven questions on suspension, builders, dispatchers, structured concurrency, cancellation, and the cold and hot streams of Flow.

Search

Loading search...