Kotlin Coroutines are frequently described as "lightweight threads." We are told that we can spin up 100,000 of them simultaneously without running out of memory.
But from the Java Virtual Machine's (JVM) perspective, there is no such thing as a "coroutine." The JVM only understands native OS-level threads (though Java Virtual Threads are changing this, they are still native abstractions under the hood). How does Kotlin bridge this gap?
To understand coroutines at a deeper level, we have to look at what the Kotlin compiler does to our code during compilation.
1. Continuation Passing Style (CPS)
When the Kotlin compiler encounters the suspend keyword, it triggers a code compilation transformation known as Continuation Passing Style (CPS).
Consider this simple suspending function:
suspend fun getUserDetails(userId: String): UserProfile {
val token = fetchAuthToken() // suspending
val profile = fetchProfile(userId, token) // suspending
return profile
}
During compilation, the compiler alters this function's signature in two major ways:
- It adds an extra parameter of type
Continuation<Any?>to the end of the argument list. - It changes the return type to
Any?.
Here is what the compiled signature looks like in pseudo-Java:
// Compiled Java Signature
public final Object getUserDetails(String userId, Continuation<? super UserProfile> completion)
Why return Any? / Object?
A suspending function might return immediately (if the data is already cached), or it might suspend. If it suspends, it returns a special sentinel value: COROUTINE_SUSPENDED. Because the function can return either your actual result (UserProfile) or the suspension token (COROUTINE_SUSPENDED), the return type must be general enough to hold both—hence Any? in Kotlin, or Object in Java bytecode.
2. The Compiler-Generated State Machine
The true magic of coroutines lies in how suspending functions resume where they left off without blocking the underlying thread. To achieve this, the Kotlin compiler transforms the body of your suspending function into a State Machine represented by an anonymous class.
This class implements the Continuation interface:
interface Continuation<in T> {
val context: CoroutineContext
fun resumeWith(result: Result<T>)
}
For every suspending call within your function, the compiler defines a state (or "label"). Let's see how our getUserDetails function is rewritten into a state machine:
// Pseudo-code of the compiler-generated State Machine
class GetUserDetailsStateMachine(
completion: Continuation<Any?>
) : CoroutineImpl(completion) {
// Local variables we need to preserve across suspension points
var userId: String? = null
var token: String? = null
var result: Any? = null
var label: Int = 0 // Tracks the state of execution
override fun invokeSuspend(result: Result<Any?>) {
this.result = result
// Invoke the original function, passing this state machine as the Continuation
getUserDetails(null, this)
}
}
Inside the modified getUserDetails function, a switch (or when) statement routes execution based on the state machine's label:
fun getUserDetails(userId: String?, completion: Continuation<Any?>): Any? {
// 1. Establish state machine wrapper
val sm = completion as? GetUserDetailsStateMachine ?: GetUserDetailsStateMachine(completion)
// 2. State Machine Routing
switch (sm.label) {
case 0:
// First run: save arguments
sm.userId = userId
sm.label = 1
// Call fetchAuthToken, passing the state machine
val res = fetchAuthToken(sm)
if (res == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
// If completed synchronously, fall through to case 1
sm.result = res
case 1:
// Resumed after fetchAuthToken
val token = sm.result as String
sm.token = token
sm.label = 2
// Call fetchProfile, passing the state machine
val res = fetchProfile(sm.userId!!, token, sm)
if (res == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
sm.result = res
case 2:
// Resumed after fetchProfile
val profile = sm.result as UserProfile
return profile
}
}
The Suspension Flow
When fetchAuthToken(sm) is called:
- If the network call is asynchronous, the function returns
COROUTINE_SUSPENDED. getUserDetailsimmediately returnsCOROUTINE_SUSPENDEDto its caller, freeing up the current thread to run other tasks.- Once the network response arrives, the client calls
sm.resumeWith(result). - This invokes
invokeSuspend(), incrementing thelabeland executinggetUserDetails(null, sm)again. - The
switchstatement jumps directly tocase 1, restoring the localuserIdand continuing execution where it left off.
This is how Kotlin avoids blocking threads: suspension is just returning a token and saving local variables to a heap-allocated state machine object.
3. Thread Orchestration: The Role of Dispatchers
While the state machine manages what happens when a coroutine pauses, Coroutine Dispatchers manage where it resumes.
On the JVM, dispatchers act as execution wrappers around Java Executors (Thread Pools):
Dispatchers.Default: Backed by a pool of threads equal to the number of CPU cores (minimum 2). It is optimized for CPU-heavy tasks like sorting lists, parsing JSON, or running mathematical models.Dispatchers.IO: Backed by an elastic thread pool (defaulting to 64 threads or the number of cores, whichever is larger). It is optimized for blocking network or disk I/O operations where threads spend most of their time waiting.
// Launching on different pools
val job = CoroutineScope(Dispatchers.Default).launch {
// Executes on CPU-bound thread pool
val result = calculateFibonacci(40)
withContext(Dispatchers.IO) {
// Suspends on Default thread pool, schedules and resumes on IO thread pool
saveToFile(result)
}
}
Thread Hand-off under the hood
When you call withContext(Dispatchers.IO):
- The compiler registers a new suspension point.
- The current coroutine (state machine) is intercepted by
Dispatchers.IO. Dispatchers.IOsubmits the coroutine's execution block as a task to its internal thread pool.- When a thread in the IO pool is free, it runs the block.
- Once complete, the coroutine is dispatched back to the original dispatcher (
Dispatchers.Defaultin this case) to resume.
4. Cooperative Multitasking and Suspension
Unlike OS-level threads, which are preemptive (the OS kernel can pause a thread at any time to run another), Kotlin Coroutines are cooperative.
A coroutine must yield control voluntarily to allow other coroutines on the same thread to run. Control is yielded when a coroutine hits a suspension point (like delay(), yield(), or a network request).
If a coroutine executes a heavy blocking loop without a suspending call, it will monopolize the thread:
// Anti-pattern: This blocks the thread pool and cannot be cancelled!
CoroutineScope(Dispatchers.Default).launch {
var count = 0
while (count < Int.MAX_VALUE) {
count++ // Blocks the execution thread entirely
}
}
To make CPU-intensive loops cooperative and cancelable, you must periodically check the coroutine's cancellation state:
// Correct cooperative loop
CoroutineScope(Dispatchers.Default).launch {
var count = 0
while (count < Int.MAX_VALUE) {
ensureActive() // Throws CancellationException if cancelled
// Or yield() to give other coroutines a chance to run
count++
}
}
Project Loom (Virtual Threads) vs. Kotlin Coroutines
With the introduction of Virtual Threads in Java 21 (Project Loom), JVM concurrency has evolved. Virtual threads are managed by the Java runtime instead of the OS kernel, allowing millions of virtual threads to run on a small pool of carrier threads.
How does this impact Kotlin Coroutines?
- Kotlin Coroutines are a language-level compiler abstraction. They work on older JVM versions (Java 8+), support non-JVM targets (iOS, WebAssembly, JS), and offer structured concurrency primitives (like
async/await,Flow, and cancellation scopes). - Virtual Threads are a JVM-level runtime abstraction. They allow legacy blocking synchronous Java code to run with near-zero overhead without rewriting the code to use reactive/callback structures.
In modern backend services, Kotlin Coroutines can run on top of Project Loom's Virtual Threads by wrapping Loom's executor in a dispatcher: Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher(), combining the rich API of coroutines with the runtime efficiency of virtual threads.
Conclusion
At their core, Kotlin Coroutines are not magic. They are a sophisticated compiler optimization. By transforming suspending functions into state machines and passing continuations, Kotlin allows us to write synchronous-looking, readable code that executes asynchronously, preserving resources and keeping applications highly responsive.
To see how to manage this asynchronous state cleanly in your mobile architectures, check out my guide on State Management in Kotlin Multiplatform, or learn how to decouple your services using dependency injection in my tutorial on Mastering DI with Koin.
Need a Concurrency & Mobile Systems Expert?
If you are looking to design high-throughput reactive systems, optimize threading performance, or implement structured coroutine architectures in your shared modules, I can help. I provide remote contracting and consulting services for engineering teams in Europe, the UK, and Canada (CEST, GMT, and EST timezone support).