The concept of coroutines and how the Kotlin compiler works under the hood
Hey there — I'm Gunner (Jung Jae-hoon), a backend engineer at ONDA working on hotel operations management solutions.
Today I want to walk you through how Coroutines actually work in Kotlin.
First things first: coroutines let you write callback-based code as if it were sequential. Here's what that looks like:
// callback version fun postItem(item: Item) { requestTokenAsync { token -> createPostAsync(token, item) { post -> processPost(post) } } }
// coroutine version suspend fun postItem(item: Item) { val token = requestToken() val post = createPost(token, item) processPost(post) }
With coroutines, you avoid callback hell and your code becomes way more readable. But how does this magic actually work?
1. Coroutines and Suspension Points
Wikipedia defines coroutines like this:
"Coroutines are computer program components that allow execution to be suspended and resumed, …" — Wikipedia
So coroutines are program components that let you pause and resume computation.
Kotlin coroutines can do exactly that. The points where execution pauses are called suspension points. If you're using IntelliJ, the IDE helpfully marks them for you:

A suspension point appears whenever you call a function declared with the suspend modifier. In the example above, requestToken(), createPost(), and processPost() are all suspend functions.
When code execution hits a suspension point, it pauses the original flow, runs that suspend function, and then resumes where it left off. Let's look at how this pause/resume mechanism works under the hood:
suspend fun main() { println("Before")
suspendCoroutine { continuation ->
thread {
println("Suspended")
Thread.sleep(1000)
continuation.resumeWith(Result.success(Unit))
println("Resumed")
}
}
println("After")
}
The main() function pauses when you call suspendCoroutine. The lambda you pass in can call continuation.resumeWith() to resume the original function (in this case, main). That's how suspension points pause execution, run a suspend function, and then pick up where they left off.
2. Continuation == Callback
If you've read about coroutines, you've seen the term Continuation everywhere. The usual explanation is "Kotlin coroutines use CPS (Continuation Passing Style) to suspend and resume."
In the example above, the lambda passed to suspendCoroutine receives a parameter called continuation. Continuation is an old concept — Scheme even has a call/cc function (call-with-current-continuation) for it.
Continuations let you pause the original function and resume it once the suspend function finishes. But what's actually inside a continuation that makes this possible?
Think about pausing a task in real life. To resume it later, you need to remember:
- Where you left off (so you know where to resume)
- The context at the time you stopped (like which Excel file you had open, which reference docs you were using)
With those two things, you can pick up exactly where you left off.
Suspending and resuming functions works the same way. To resume a function, you need:
- How far you got (which line of code)
- The state at pause time (e.g.,
varA = 10,varB = "This is string")
If you store this info in a continuation and pause, you can finish other work and then use the continuation to resume the original function exactly where it stopped.
Sound familiar? It's basically a callback.
Callbacks let you pass a lambda to run at a specific moment. Continuations do the same thing — you pass a lambda to run at a specific moment.
The difference is when that moment is. With continuations, the lambda runs now and the original function resumes later (after the lambda finishes). With callbacks, the lambda runs later (whenever the callback gets invoked) and the original function continues now. Timing differs, but conceptually they're very similar.
3. The Kotlin Compiler Does the Heavy Lifting
So now we know about suspension points and continuations. But who actually implements all this?
The Kotlin compiler does.
When the Kotlin compiler sees a function with the suspend modifier, it transforms the code like this:
// Kotlin suspend fun createPost(token: Token, item: Item): Post { ... }
// Transformed to this Java code under the hood
// Java/JVM Object createPost(Token token, Item item, Continuation<Post> cont) { ... }
Continuation is an interface that looks like this:
// Kotlin ~v1.2 interface Continuation<in T> { val context: CoroutineContext fun resume(value: T) fun resumeWithException(exception: Throwable) }
// Kotlin v1.3~ interface Continuation<in T> { val context: CoroutineContext fun resumeWith(result: Result<T>) }
You can see that calling resumeWith passes a result and resumes the original function. But how does it know where to resume? After all, there are multiple suspension points.
Kotlin uses labels to track which suspension point you're at. Here's a simplified example:
suspend fun requestToken() { ... }
suspend fun createPost(token, item) { ... }
suspend fun processPost(post) { ... }
suspend fun postItem(item: Item) { // LABEL 0 val token = requestToken() // LABEL 1 val post = createPost(token, item) // LABEL 2 processPost(post) }
Each suspension point gets a label. If you remember the label, you can resume at the right spot next time:
suspend fun postItem(item: Item) { switch (label) { case 0: val token = requestToken() case 1: val post = createPost(token, item) case 2: processPost(post) } }
Where's the label stored? In the continuation. The continuation holds the label, and you branch on it to resume from the right point:
fun postItem(item: Item, cont: Continuation) { val sm = object : CoroutineImpl { ... } when (sm.label) { 0 -> val token = requestToken(sm) 1 -> val post = createPost(token, item, sm) 2 -> processPost(post) } }
This is starting to take shape. On first call, if there's no continuation, you create one. Then you reuse it at each suspension point.
But wait — something's missing. There's no code to save the label. And when label = 1 and we call createPost, where does the item value come from? At that point, the original item argument is long gone — we're resuming from resumeWith.
Right. So you also need to save the state in the continuation and retrieve it later:
fun postItem(item: Item, cont: Continuation) { val sm = object : CoroutineImpl { ... } when (sm.label) { 0 -> { sm.item = item sm.label = 1 val token = requestToken(sm) } 1 -> { val item = sm.item val token = sm.result as Token sm.label = 2 val post = createPost(token, item, sm) } 2 -> processPost(post) } }
Tracking suspension points and managing state is what suspendCoroutine() and Continuation do. The Kotlin compiler automatically transforms code with the suspend modifier to handle all this.
To be clear: the actual bytecode is more complex. If you decompile it, you'll see gnarly Java. But I've kept these examples in Kotlin for clarity. The underlying flow is the same.
4. Coroutine Extensions for Future-Like Types
By now you get how callback-style code becomes direct-style. But does this actually improve performance? Or is it just a style thing?
The real win with coroutines is efficient thread usage. When you suspend at a suspension point and start another task, you can run that task on a specific thread pool. Meanwhile, the original thread is idle until the work resumes — so you can use it for something else.
This is huge when you're making multiple async calls at once. If those calls are made with suspend functions, you get way more efficient thread utilization. Plus, there's another benefit.
Most JVM async libraries ship their own Future-like types:
- Guava:
ListenableFuture - RxJava:
Observable - JDK8:
CompletableFuture - …
They're all different classes, but they work like Future. Kotlin coroutines provide extensions that integrate with these future-like types.
That means you can work with any of them in a uniform way. For example, you can use await() on a ListenableFuture, CompletableFuture, Observable, Promise — whatever. It all works the same. This is possible thanks to Kotlin's extension functions. The end result: you can write integration code in a consistent way across libraries.
// Java function using Guava public ListenableFuture<Image> guavaLoadImageAsync(String name) { … }
// Java function using RxJava public Observable<Image> rjLoadImageAsync(String name) { … }
// Java function that combines two images public Image combineImage(Image image1, Image image2) { … }
// Kotlin code using both // Loads two images and returns a CompletableFuture fun combineImagesAsync(name1: String, name2: String): CompletableFuture<Image> = future { val future1 = guavaLoadImageAsync(name1) val future2 = rjLoadImageAsync(name2) combineImages(future1.await(), future2.await()) }
💡 Kotlin Compiler
-
The Kotlin compiler transforms functions with the
suspendmodifier. -
The transformed function uses a continuation (like a callback) to manage suspension points and state — letting it pause and resume execution.
-
While paused, the original thread is free for other work — making this approach efficient.
-
Coroutine extensions for future-like types let you handle different async libraries in a uniform way.
That wraps up this look at the concept of coroutines and how the Kotlin compiler works under the hood. Thanks to these future-like extensions, coroutines let you handle different async types with identical code patterns. Thanks for reading.