← Back to books

Chapter 2 — Your First Coroutine

In Chapter 1, suspend was a promise. In this chapter, we cash it in: what a suspending function really is, how to run one, and what "suspension" looks like once you stop trusting the magic.

2.1 The suspend Keyword

A suspending function is an ordinary function with one extra ability: it can pause and resume without blocking the thread it runs on. You mark it with the suspend modifier:

suspend fun fetchUser(id: String): User {
    delay(1000)               // pause for 1s, without blocking the thread
    return User(id, "Mahmoud")
}

delay is the coroutine world's equivalent of Thread.sleep, with one all-important difference. Thread.sleep(1000) blocks — the thread sits there doing nothing for a full second. delay(1000) suspends — the coroutine steps aside for a second and the thread is released to do other work. After a second, the coroutine resumes on the next line. Same observable delay, completely different cost.

Here's the rule that governs everything: a suspending function can only be called from another suspending function, or from inside a coroutine. It cannot be called from regular code.

fun regularFunction() {
    fetchUser("1")  // ❌ compile error: suspend function called from non-suspend context
}

suspend fun anotherSuspendFunction() {
    fetchUser("1")  // ✅ fine — we're already in a suspend function
}

Why the restriction? Because suspension only works if the caller knows how to handle a pause. A regular function has no machinery for "pause here and come back later." A suspend function does. So suspend is contagious in the upward direction — call a suspend function and your function must become suspend too, all the way up until you hit a coroutine builder, which is where a regular function finally bridges into the coroutine world.

This raises the obvious question: if suspend functions can only be called by other suspend functions, how does the very first one get called? That's what builders are for.

2.2 Bridging Into Coroutines: runBlocking

To call a suspend function from regular code, you need to start a coroutine. The simplest builder for learning (and for tests, and for main functions) is runBlocking:

fun main() {
    println("Before")
    runBlocking {
        val user = fetchUser("1")   // ✅ we're inside a coroutine now
        println("Got: ${user.name}")
    }
    println("After")
}

Output:

Before
Got: Mahmoud
After

runBlocking does exactly what its name says: it blocks the current thread until the coroutine inside it completes. It is a bridge between the blocking world (regular functions, main, JUnit tests) and the suspending world (everything inside the lambda).

That blocking behavior is also why you almost never use runBlocking in production Android code. Blocking is the very thing we're trying to escape — using runBlocking on the main thread reintroduces the freeze from Chapter 1. Its legitimate homes are:

  • The main() function of a command-line program.
  • Unit tests (though we'll prefer runTest later — see Chapter 18).
  • Occasionally, bridging into a legacy blocking API at the edge of your system.

Inside an Android app, you'll start coroutines with launch and async instead, tied to a proper scope. Let's meet those.

2.3 The Two Workhorses: launch and async

To start a coroutine that does not block the caller, you use launch or async. Both are extension functions on CoroutineScope (we'll formally define scope in Chapter 4; for now, think of it as "the context a coroutine lives in"). They differ in one way: what they give back.

launch — fire and forget

launch starts a coroutine and returns a Job, a handle you can use to cancel it or wait for it. It does not return a result. Use it for work whose side effects matter but whose return value you don't need — saving to a database, updating UI state, logging.

fun loadData(scope: CoroutineScope) {
    scope.launch {
        val user = fetchUser("1")
        updateUi(user)          // side effect; no value handed back to caller
    }
    // this line runs immediately — launch did not block
}

async — I need a result back

async starts a coroutine and returns a Deferred<T>, a promise of a future value. You call .await() on it (a suspending call) to get the result when it's ready.

suspend fun loadData(scope: CoroutineScope) {
    val deferred: Deferred<User> = scope.async {
        fetchUser("1")
    }
    val user = deferred.await()   // suspends until the result is ready
    updateUi(user)
}

On its own, a single async immediately followed by await is just a more roundabout suspend call. Its real power shows up with parallelism, which is the next section.

Rule of thumb: launch when you don't need a result; async when you do. Reaching for async and immediately awaiting it on the next line is usually a sign you wanted a plain suspend call.

2.4 Running Work in Parallel

Return to the profile loader. Suppose the user and their settings come from two independent endpoints — neither depends on the other. Written sequentially, we pay for both round-trips one after another:

suspend fun loadProfile(): Profile {
    val user = fetchUser("1")        // 1 second
    val settings = fetchSettings("1") // 1 second
    return Profile(user, settings)    // total: ~2 seconds
}

Two seconds, even though the calls don't depend on each other. We're leaving time on the table. With async, we start both before awaiting either:

suspend fun loadProfile(): Profile = coroutineScope {
    val userDeferred = async { fetchUser("1") }         // starts immediately
    val settingsDeferred = async { fetchSettings("1") } // starts immediately

    val user = userDeferred.await()       // both are already in flight
    val settings = settingsDeferred.await()
    Profile(user, settings)               // total: ~1 second
}

Both requests are now in flight at the same time, so the total time is roughly the longer of the two rather than their sum. This is the canonical use of async: launch independent work concurrently, then await all results.

(You'll notice coroutineScope { } wrapping this. That's a suspending function that creates a scope for the async calls and waits for all of them to finish before returning. It's a cornerstone of structured concurrency, and Chapter 5 is devoted to it. For now, read it as "a safe place to start child coroutines.")

A common shorthand when you have a list of items to process in parallel is awaitAll:

suspend fun fetchAllUsers(ids: List<String>): List<User> = coroutineScope {
    ids.map { id -> async { fetchUser(id) } }  // launch all
       .awaitAll()                              // await all
}

2.5 What Suspension Actually Is (Under the Hood)

We've used the word "suspend" a dozen times. Now let's earn it. How can a function pause in the middle and resume later, with all its local variables intact, without blocking a thread?

The answer is that the Kotlin compiler rewrites your suspend functions. You write straight-line code; the compiler transforms it into a state machine. This transformation is called Continuation-Passing Style (CPS), and once you see it, suspension stops being magic.

The hidden parameter

Every suspend function, after compilation, secretly takes one extra parameter: a Continuation. A continuation is, quite literally, "the rest of the computation" — a callback that knows how to resume the function from where it paused. Conceptually:

// What you write:
suspend fun fetchUser(id: String): User

// What the compiler produces (roughly):
fun fetchUser(id: String, continuation: Continuation<User>): Any?

The Continuation interface is small:

interface Continuation<in T> {
    val context: CoroutineContext
    fun resumeWith(result: Result<T>)
}

When a suspend function hits a suspension point (like delay or a network call) and there's no value yet, it returns a special marker called COROUTINE_SUSPENDED and stashes its continuation. The thread is now free. Later, when the awaited thing is ready, someone calls continuation.resumeWith(result), and the function picks up where it left off.

So under the hood, coroutines are callbacks — the exact thing we fled from in Chapter 1. The difference is that the compiler writes the callbacks for you, perfectly, every time, and lets you keep your straight-line source code. Callback hell still exists; it's just been pushed down into generated code you never read.

The state machine

How does a resumed function know where to pick up? The compiler assigns each suspension point a numbered state (a label), and turns your function body into a switch over those states. A simplified picture of a two-step function:

// Source:
suspend fun loadProfile(id: String): Profile {
    val user = fetchUser(id)        // suspension point 0
    val settings = fetchSettings(id) // suspension point 1
    return Profile(user, settings)
}

// Compiled shape (heavily simplified pseudocode):
fun loadProfile(id: String, completion: Continuation<Profile>): Any? {
    val sm = completion as? ProfileStateMachine ?: ProfileStateMachine(completion)
    when (sm.label) {
        0 -> {
            sm.label = 1
            val result = fetchUser(id, sm)            // pass the state machine as the continuation
            if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            sm.user = result as User
            // fall through if fetchUser didn't actually suspend
        }
        1 -> {
            sm.user = sm.result as User
            sm.label = 2
            val result = fetchSettings(id, sm)
            if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            sm.settings = result as Settings
        }
        2 -> {
            return Profile(sm.user, sm.result as Settings)
        }
    }
}

Don't memorize this. The point is the shape:

  • Local variables (user, settings) become fields on the state machine object, which is how they survive across suspension.
  • Each suspension point advances a label.
  • The same function is re-entered on resume, jumping straight to the right state via the when.
  • If a call suspends, the function returns COROUTINE_SUSPENDED and the thread is released. If it doesn't actually suspend (the value was ready immediately), execution falls straight through with no pause at all.

That last point is a quiet performance win: suspension is only paid for when a real wait happens. A suspend function whose data is already cached runs at essentially the speed of a normal function call.

Why this means suspension is cheap

Now the lightweight claim from Chapter 1 makes sense. A suspended coroutine is just a state machine object on the heap holding a label and a few fields. It's not a thread. It's not an OS resource. It's a small allocation. That's why you can have hundreds of thousands of them — they cost about as much as any other object, and a single thread can resume them one after another as their data becomes ready.

2.6 Suspension Points Are Visible

A practical consequence of all this: you can see exactly where your coroutine might pause. Every call to a suspend function is a potential suspension point. In IntelliJ and Android Studio, these are marked in the gutter with a small icon (a stylized "suspension" arrow).

This visibility is a real advantage over threads, where a context switch can happen anywhere, invisibly, between any two bytecode instructions. With coroutines, suspension only happens at suspend calls. Between two suspension points, your code runs sequentially and atomically with respect to other coroutines on the same dispatcher — you don't need a lock to protect a variable you only touch between suspension points on a single-threaded dispatcher.

suspend fun example() {
    var counter = 0
    counter++            // no suspension here — runs straight through
    delay(100)           // ⬅ suspension point: another coroutine may run now
    counter++            // resumes here later
    println(counter)
}

Knowing where your code can pause — and therefore where state can change underneath you — is a superpower when reasoning about correctness. Hold onto it; it'll matter a lot when we discuss shared mutable state and cancellation.

2.7 A Complete, Runnable Example

Let's put the chapter together in something you can paste into a Kotlin playground or a main function and run. It shows sequential vs. parallel timing, the thing you'll care about most in real apps.

import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

suspend fun fetchUser(id: String): String {
    delay(1000)                       // simulate a 1s network call
    return "User($id)"
}

suspend fun fetchSettings(id: String): String {
    delay(1000)
    return "Settings($id)"
}

fun main() = runBlocking {
    // Sequential: ~2000 ms
    val sequentialTime = measureTimeMillis {
        val user = fetchUser("1")
        val settings = fetchSettings("1")
        println("Sequential -> $user, $settings")
    }
    println("Sequential took $sequentialTime ms\n")

    // Parallel: ~1000 ms
    val parallelTime = measureTimeMillis {
        coroutineScope {
            val user = async { fetchUser("1") }
            val settings = async { fetchSettings("1") }
            println("Parallel -> ${user.await()}, ${settings.await()}")
        }
    }
    println("Parallel took $parallelTime ms")
}

Expected output (timings approximate):

Sequential -> User(1), Settings(1)
Sequential took 2013 ms

Parallel -> User(1), Settings(1)
Parallel took 1007 ms

Two structurally identical pieces of logic; one takes twice as long because it waits for each step before starting the next. Recognizing when work is independent and can run in parallel is one of the highest-leverage skills in concurrent programming, and async is how you express it.

2.8 Common Beginner Mistakes

A few traps that catch nearly everyone early. We'll revisit each in depth later, but naming them now saves pain.

Wrapping a suspend call in a thread or runBlocking "to be safe." You don't need to. A suspend function already runs without blocking. Wrapping fetchUser in runBlocking inside another coroutine just reintroduces blocking.

// ❌ pointless and harmful
launch {
    val user = runBlocking { fetchUser("1") }  // blocks the thread the coroutine runs on
}

// ✅
launch {
    val user = fetchUser("1")
}

Using async when you mean launch. If you're not going to await a result, launch is the correct, clearer choice. An async whose Deferred is never awaited can also silently swallow exceptions until something awaits it.

Thinking async makes things parallel by itself. Parallelism comes from starting multiple async blocks before awaiting any of them. If you await each one immediately, you're back to sequential:

// ❌ still sequential — await blocks progress before the next async starts
val user = async { fetchUser("1") }.await()
val settings = async { fetchSettings("1") }.await()

Forgetting that suspension is cooperative. A while (true) { computeHeavyStuff() } loop with no suspend call inside will never give the thread up and never respond to cancellation. We'll deal with this properly in Chapter 7.

2.9 Summary

  • A suspending function (suspend fun) can pause and resume without blocking its thread. delay suspends; Thread.sleep blocks.
  • Suspend functions can only be called from other suspend functions or from inside a coroutine. suspend propagates upward until it reaches a coroutine builder.
  • runBlocking bridges regular code into the coroutine world by blocking the current thread — great for main and tests, wrong for production Android.
  • launch starts a coroutine and returns a Job (fire-and-forget). async starts a coroutine and returns a Deferred<T> whose .await() yields a result.
  • Real parallelism comes from starting multiple async blocks before awaiting them; awaitAll collects a list of them.
  • Under the hood, the compiler rewrites suspend functions into state machines using continuations (Continuation-Passing Style). Local variables become fields; suspension points become states. Suspended coroutines are cheap heap objects, not threads — which is why you can have hundreds of thousands of them.
  • Suspension points are visible at every suspend call, making it possible to reason precisely about where your coroutine can pause and where shared state can change.

In the next chapter, we go deeper on the builders — launch, async, runBlocking, and friends like withContext — and exactly when to reach for each.


Next: Chapter 3 — Coroutine Builders, a tour of every way to start a coroutine and the decision rules for choosing among them.