01 — Introduction
A deep exploration of coroutines, suspend functions, Deferred, Flow, and structured concurrency — the architecture that makes Kotlin async feel natural.
01 — Foundation
Computers are fast. Networks are slow. Disks are slower. Asynchronous programming bridges that gap.
Synchronous code is simple: each line waits for the previous one to finish. This works fine for CPU work. But the moment your program talks to a database, reads a file, or fires an HTTP request, the CPU sits idle — burning time waiting for the outside world.
In a Kotlin backend handling thousands of requests per second, blocking threads is catastrophic. One slow database query holds an entire thread hostage. Kotlin Coroutines allow a small thread pool to juggle thousands of concurrent operations — not by doing them all at once, but by suspending and resuming intelligently.
A suspend function pauses without blocking its thread. The thread is freed for other work; the coroutine resumes later on any available thread in the dispatcher pool.
Coroutines live in a CoroutineScope. Cancel a scope and all child coroutines are cancelled. A child failure propagates to its parent. No silent leaks.
OS threads cost ~1MB each. A JVM can run tens of thousands of coroutines on a handful of threads, making Kotlin ideal for high-throughput I/O servers and Android UIs.
| Approach | Kotlin API | Blocking? | Use Case |
|---|---|---|---|
| Synchronous | Plain functions | Yes blocks thread | CPU-bound work, scripts |
| Threads | Thread, Executor | No, but heavy | CPU-parallel work, legacy APIs |
| Coroutines | launch, async, suspend | No suspends | I/O-bound, Android UI, servers |
| Flow | Flow<T>, StateFlow | No cold/hot stream | Streaming data, events, UI state |
02 — Architecture
The Kotlin compiler transforms suspend functions into state machines. No OS threads required — just a Continuation object and a cooperative scheduler.
When the compiler sees a suspend fun, it rewrites it into a state machine. Each suspension point becomes a state. A Continuation<T> captures all local variables and current state, so execution can pause and resume from exactly the right point.
A CoroutineDispatcher decides which thread runs the coroutine. Dispatchers.IO is a large pool for blocking I/O. Dispatchers.Default is CPU-sized. Dispatchers.Main marshals to the Android/Swing UI thread.
import kotlinx.coroutines.* fun main() = runBlocking { // bridges blocking world → coroutines // launch — fire-and-forget, returns Job val job: Job = launch { delay(1000L) // suspend (non-blocking sleep) println("World!") } println("Hello,") job.join() // suspend until job completes // async — returns Deferred<T> (a result later) val deferred: Deferred<String> = async { delay(500L) "computed result" } println(deferred.await()) // suspend until ready } // Output: // Hello, // World! // computed result
03 — Era I
Before coroutines, Kotlin and Java relied on callbacks and listeners. Simple for one async step, treacherous when chained.
Android developers know this well from Retrofit's enqueue, Room database callbacks, and location listeners. Each async call requires passing a lambda — and when operations depend on each other, the lambdas nest deeply.
// The "Pyramid of Doom" — callback style getUser(userId) { userResult -> if (userResult.isError) { handleError(userResult.error); return } getOrders(userResult.data.id) { ordersResult -> if (ordersResult.isError) { handleError(ordersResult.error); return } getProducts(ordersResult.data.first().id) { productsResult -> if (productsResult.isError) { handleError(productsResult.error); return } render(productsResult.data) // finally! } } } // ── The coroutine equivalent — flat and readable ─────── suspend fun loadDashboard(userId: String) { val user = getUser(userId) val orders = getOrders(user.id) val products = getProducts(orders.first().id) render(products) // same result, zero nesting } // ── Wrapping a callback API with suspendCoroutine ────── suspend fun locationOnce(): Location = suspendCancellableCoroutine { cont -> val cb = LocationCallback { loc -> cont.resume(loc) } locationClient.requestUpdate(cb) cont.invokeOnCancellation { locationClient.remove(cb) } }
You hand your lambda to a third-party API and trust it calls correctly — once, on a sensible thread. With coroutines, you own the control flow; suspension is explicit.
Callbacks need manual null/error checks at every nested level. Coroutines use standard try/catch, and exceptions propagate naturally up the call chain.
Callbacks fire on arbitrary threads, requiring manual synchronization. Coroutines with withContext and Dispatchers make thread control explicit and safe.
Cancelling a callback chain needs custom tokens everywhere. Coroutine cancellation is cooperative and automatic — cancel the scope, all children stop cleanly.
04 — Era II
Deferred<T> is Kotlin's non-blocking handle to a future result — analogous to a Promise or Future, but deeply integrated with structured concurrency.
Call async { } and Kotlin launches a coroutine immediately, returning a Deferred<T>. The coroutine runs concurrently. Call .await() and the current coroutine suspends — no thread blocked — until the result is ready.
Coroutine is running. await() suspends the caller until completion.
Coroutine returned a value. await() returns immediately with the result.
Coroutine threw or was cancelled. await() re-throws the exception.
suspend fun loadDashboard() = coroutineScope { // ── Sequential (slow — each suspends before the next) ─ val user = fetchUser() // ~600ms val profile = fetchProfile() // then ~400ms → total ~1000ms // ── Concurrent with async (fast) ────────────────────── val dUser = async { fetchUser() } // starts immediately val dProfile = async { fetchProfile() } // starts immediately val dOrders = async { fetchOrders() } // starts immediately // all three in parallel — total ≈ max(600, 400, 800) = 800ms Dashboard( user = dUser.await(), profile = dProfile.await(), orders = dOrders.await() ) } // awaitAll — idiomatic shorthand for a list of Deferred val results: List<Data> = (1..10) .map { id -> async { api.fetch(id) } } .awaitAll()
Unlike Java's Future.get(), which blocks a thread, Deferred.await() only suspends the coroutine. The thread is available for other work while waiting — this is what makes massive concurrency possible on a small thread pool.
05 — Core Concept
A suspend fun is the heart of Kotlin async. It can pause without blocking, resume on any thread, and compose naturally with try/catch and standard control flow.
The suspend modifier is a compile-time marker. It tells the compiler: "this function may pause execution." Under the hood, the compiler adds an implicit Continuation<T> parameter — effectively a resumable callback — without you ever writing one.
// ── Basic suspend function ───────────────────────────── suspend fun fetchUser(id: String): User { delay(500) // suspends without blocking thread return api.getUser(id) } // ── withContext — switch dispatcher mid-coroutine ────── suspend fun readFile(path: String): String = withContext(Dispatchers.IO) { // moves to IO thread pool File(path).readText() } // returns to original dispatcher // ── Reads top-to-bottom like synchronous code ───────── suspend fun loadAll(userId: String) { val user = fetchUser(userId) val orders = fetchOrders(user.id) val products = fetchProducts(orders.first().id) render(products) } // ── Suspension order in a single coroutine ──────────── fun main() = runBlocking { println("1 — sync") launch { println("4 — child coroutine (launched)") } // yield() suspends and lets other coroutines run yield() println("3 — after yield") } // Output: 1 — sync → 3 — after yield → 4 — child coroutine
06 — Mastery
Beyond the basics lie patterns that separate competent Kotlin developers from experts — retry logic, Flow streams, semaphore-bounded parallelism, and timeout handling.
// ── 1. Retry with exponential backoff ───────────────── suspend fun <T> withRetry(maxAttempts: Int = 3, block: suspend () -> T): T { repeat(maxAttempts - 1) { attempt -> try { return block() } catch (e: CancellationException) { throw e } // never swallow! catch (e: Exception) { delay((2.0.pow(attempt) * 1000).toLong()) // 1s, 2s, 4s… } } return block() // last attempt — let it throw } // ── 2. Timeout ───────────────────────────────────────── val result = withTimeout(3000L) { api.fetchData() } val result = withTimeoutOrNull(3000L) { api.fetchData() } ?: fallbackData() // null on timeout → use fallback // ── 3. Flow — cold async streams ─────────────────────── fun paginate(url: String): Flow<List<Item>> = flow { var next: String? = url while (next != null) { val page = api.fetch(next) emit(page.items) // suspend + push each page downstream next = page.nextUrl } }.flowOn(Dispatchers.IO) // collecting a Flow with operators paginate("/api/items") .filter { items -> items.isNotEmpty() } .map { items -> items.filter { it.active } } .collect { page -> processPage(page) } // ── 4. Concurrency limiting with Semaphore ──────────── val semaphore = Semaphore(5) // max 5 concurrent coroutines items .map { item -> async { semaphore.withPermit { process(item) } } } .awaitAll()
07 — Interactive
Observe how different Kotlin coroutine patterns — sequential, concurrent, Flow — execute with real timing.
08 — Robustness
Coroutine exceptions follow structured concurrency rules. An unhandled exception in a child cancels the parent scope — unless you opt into a SupervisorJob.
// ── Pattern 1: try/catch in suspend functions ────────── suspend fun safeLoad(): Data? { return try { fetchData() } catch (e: NetworkException) { logger.warn("Network failed, using cache") getCachedData() } catch (e: CancellationException) { throw e // always re-throw cancellation! } finally { cleanup() // always runs, even if cancelled } } // ── Pattern 2: CoroutineExceptionHandler ────────────── val handler = CoroutineExceptionHandler { _, throwable -> logger.error("Unhandled coroutine exception", throwable) } val scope = CoroutineScope(Dispatchers.IO + handler) scope.launch { riskyOperation() } // ── Pattern 3: SupervisorJob — isolate child failures ─ val supervisor = CoroutineScope(SupervisorJob() + Dispatchers.Default) supervisor.launch { /* failure here does NOT cancel siblings */ } supervisor.launch { /* this keeps running independently */ } // ── Pattern 4: runCatching — Result<T> type ─────────── suspend fun safeCall(): Result<Data> = runCatching { fetchData() } safeCall() .onSuccess { data -> render(data) } .onFailure { err -> showError(err) } .recover { _ -> fallback() } // transform failure to value
If you catch Exception broadly, always re-throw CancellationException. Swallowing it breaks structured concurrency — cancelled coroutines will appear to keep running.
A regular Job fails the whole scope on any child error. SupervisorJob isolates failures — ideal for ViewModel scopes where one request failing shouldn't kill unrelated work.
runCatching { } wraps a suspend block into Result<T>, enabling functional-style error handling with map, recover, and fold.