Kotlin Coroutines feel like magic. You write sequential-looking code that somehow runs asynchronously without blocking the UI. The magic evaporates once you understand what the Kotlin compiler actually generates. Under every suspend function is a plain old callback interface — just one you never have to write yourself.
The Problem Coroutines Solve
Before coroutines, running code on a background thread and getting a result back required a callback — in Java, that means an interface:
interface Listener {
fun onComplete(result: String)
}
fun doTaskInBackground(listener: Listener) {
Thread {
Thread.sleep(2000)
listener.onComplete("Task done")
}.start()
}
The calling code passes an anonymous implementation:
doTaskInBackground(object : Listener {
override fun onComplete(result: String) {
println(result)
}
})
This works for one task. For two sequential tasks that depend on each other, you nest callbacks. For three, you nest deeper. This is called callback hell — code that is technically correct but nearly unreadable and very hard to maintain.
Java cannot return a value from a background thread to the calling thread directly. The interface is the only way to bridge that gap.
How Coroutines Fix This
A suspend function looks like it returns normally:
suspend fun doTask(): String {
delay(2000)
return "Task done"
}
// Calling it looks synchronous
lifecycleScope.launch {
val result = doTask()
println(result)
}
No interface. No nesting. Sequential, readable code. But Kotlin ultimately compiles to JVM bytecode, and the JVM has the same constraint Java does — you cannot return from a background thread to the calling thread without some form of callback.
So the compiler generates the callback automatically.
The Continuation Interface
The Kotlin coroutines library defines an interface called Continuation:
fun interface Continuation<in T> {
val context: CoroutineContext
fun resumeWith(result: Result<T>)
}
This is structurally identical to the manual Listener interface. resumeWith is onComplete. The concept is exactly the same — it is just generated for you by the compiler rather than written by hand.
When you write:
suspend fun doTask(): String {
delay(2000)
return "Task done"
}
The compiler transforms it to something like:
// Conceptual representation — not exact bytecode
fun doTask(continuation: Continuation<String>): Any {
when (continuation.label) {
0 -> {
continuation.label = 1
delay(2000, continuation) // suspend point
}
1 -> {
continuation.resumeWith(Result.success("Task done"))
}
}
return COROUTINE_SUSPENDED
}
Two things are injected into every suspend function:
- A
Continuationparameter — the callback that will be called when the function resumes. - A
label/ switch-case mechanism — tracks which part of the function to execute next after a suspend point.
This pattern is called Continuation Passing Style (CPS) — a common interview topic. The answer: suspend functions are internally converted into functions that accept a Continuation parameter and use a state machine to resume execution at the right point.
100k Coroutines vs 100k Threads
This difference becomes concrete in an experiment:
// 100,000 coroutines — app stays alive
repeat(100_000) {
GlobalScope.launch {
delay(5000L)
}
}
// 100,000 threads — OutOfMemoryError
repeat(100_000) {
Thread {
Thread.sleep(5000L)
}.start()
}
Launching 100,000 threads crashes the app. Launching 100,000 coroutines does not even cause a hiccup.
Why? A thread is a heavyweight OS-level object. It reserves roughly 512 KB of stack memory. 100,000 threads × 512 KB = ~50 GB — far beyond any device’s available RAM.
A coroutine is a lightweight Kotlin object. Its state (local variables, the Continuation callback, the label) fits in a few hundred bytes. While a coroutine is suspended — sitting at a delay() or a network call — it occupies no OS thread. It is just a data object in the heap, waiting to be resumed.
All 100,000 coroutines share a small thread pool (typically 4–8 threads on a modern device). Threads execute whichever coroutine is ready to run. Coroutines that are suspended sit aside in memory and let other coroutines use the threads.
The Idle Thread Problem
There is a subtler advantage coroutines have over a raw thread pool. Consider this dependency chain:
Task 1 (Thread 1) → must complete before Task 3 can start
Task 2 (Thread 2) → independent, can run at any time
Task 3 (Thread 1) → depends on Task 2's result
Task 4 → independent, can run at any time
With a raw thread pool:
Thread 1 finishes Task 1 and waits for Task 2’s result before it can start Task 3. During that wait, Thread 1 is blocked — it runs a while loop internally, spinning the CPU while doing no useful work. Task 4 sits in the queue. Thread 1 cannot help because it is stuck waiting.
Thread 1: [Task 1 ✓] [waiting...waiting...waiting] [Task 3]
Thread 2: [Task 2 ✓]
Thread 1: Could have done Task 4 during the wait — but it is blocked
With coroutines:
Coroutine 1 reaches the suspension point (waiting for Task 2). It does not block Thread 1. Instead, it suspends — the thread is released. Thread 1 immediately picks up Task 4 from the queue.
Thread 1: [Task 1 ✓] [Task 4 ✓] [Task 3 — resumes when Task 2 done]
Thread 2: [Task 2 ✓]
Thread 1: No idle time — cooperative scheduling fills the gap
This is cooperative scheduling: coroutines voluntarily yield the thread at suspension points, allowing other coroutines to run. Because coroutines are designed to suspend rather than block, there is very little chance a thread ever sits idle.
This is why a well-written coroutine program can outperform a thread pool program even with the same number of underlying OS threads.
Summary
| Concept | How it works |
|---|---|
suspend function | Compiler generates a Continuation parameter and a label-based state machine |
| Continuation Passing Style (CPS) | The pattern of passing a callback (Continuation) into a function so it can resume asynchronously |
| 100k coroutines vs threads | Coroutines = lightweight objects; threads = heavyweight OS resources. Coroutines share a small thread pool. |
| Idle thread problem | Thread pool threads block while waiting; coroutine threads suspend and immediately pick up other work |
| All frameworks internally use ThreadPool | AsyncTask, RxJava, Coroutines — all dispatch to a ThreadPoolExecutor underneath |
The next post covers coroutine dispatchers and scopes — Main.immediate vs Main, lifecycleScope vs GlobalScope, launch vs async, and how exceptions propagate differently between them.