// deep dive reference

Kotlin
Coroutines
Explained

Everything from suspend functions to continuations, state machines, cancellation, and wrapping callbacks — built from first principles.

suspend fun Continuation CoroutineScope Job & Cancellation Dispatchers suspendCancellableCoroutine

Suspend Functions

A suspend function can pause execution without blocking the thread it runs on. The thread is released while waiting, then the function resumes — possibly on a different thread.

Pause

Saves its state — local variables, execution position — and suspends.

🔓

Release

The thread is returned to the pool. Other work can run on it.

Resume

When the result is ready, the coroutine wakes up exactly where it left off.

The bookmark analogy: A suspend function is like placing a bookmark in a book before answering a phone call. When the call ends, you open exactly to that page and continue.

// what YOU write
suspend fun loadProfile(): Profile {
    val user  = fetchUser()    // ← suspension point
    val posts = fetchPosts()   // ← suspension point
    return Profile(user, posts)
}

Can a suspend function have NO suspension points?

Yes. It's valid Kotlin — it just never actually suspends. The compiler will warn you with "Redundant 'suspend' modifier". This is common when overriding an interface that declares suspend.

Has suspension points?Valid?Actually suspends?Compiler warning?
Yes✓ Yes✓ YesNo
No✓ Yes✗ No⚠ Redundant suspend

Identifying Suspension Points

The golden rule: Every call to a suspend fun is a suspension point. That's it.

suspend fun loadData() {
    println("start")        // regular fun  → NOT a suspension point
    val a = fetchUser()    // suspend fun  → SUSPENSION POINT
    println("got user")     // regular fun  → NOT a suspension point
    val b = fetchPosts()   // suspend fun  → SUSPENSION POINT
    println("done")         // regular fun  → NOT a suspension point
}

Built-in suspension points

CallWhy it suspends
delay(ms)Non-blocking sleep — releases thread
withContext { }Suspends to switch dispatcher/thread
yield()Gives other coroutines a turn
flow.collect { }Suspends waiting for each emission
channel.send()Suspends if channel is full
channel.receive()Suspends if channel is empty
deferred.await()Suspends until result is ready
job.join()Suspends until job completes
mutex.lock()Suspends if lock is taken

The blocking trap — regular fun that takes time

If a regular (non-suspend) function takes time, the coroutine cannot suspend there. It holds the thread hostage.

// BAD — blocks the thread, defeats coroutines
suspend fun loadData() {
    val result = heavyCompute()   // regular fun — thread stuck!
}

// GOOD — offload to another thread
suspend fun loadData() {
    val result = withContext(Dispatchers.Default) {
        heavyCompute()   // same regular fun, but thread is released
    }
}
Work typeDispatcher
CPU heavy (sorting, parsing)Dispatchers.Default
File / network / databaseDispatchers.IO
UI updatesDispatchers.Main

The State Machine

The Kotlin compiler transforms every suspend function into a state machine using Continuation Passing Style (CPS). Each suspension point becomes a numbered case label.

// What YOU write:
suspend fun loadData() {
    val a = fetchUser()    // suspension point #0
    val b = fetchPosts()   // suspension point #1
}

// What the COMPILER generates:
Object loadData(Continuation cont) {
    switch(cont.label) {
        case 0:
            cont.label = 1
            result = fetchUser(cont)
            if (result == SUSPENDED) return SUSPENDED  // pause
        case 1:
            cont.label = 2
            result = fetchPosts(cont)
            if (result == SUSPENDED) return SUSPENDED  // pause
        case 2:
            return  // done
    }
}

Mental model: Think of the compiler as taking scissors to your function — cutting at every suspend call. Each piece between cuts becomes one case in the switch.

How the compiler decides

The compiler does not "decide" suspension points — you implicitly declare them. Every call to a suspend fun tells the compiler: cut here. Regular function calls are never cut.

The Continuation

A Continuation is the bookmark — it captures everything needed to resume a suspended coroutine. The compiler adds it as a hidden parameter to every suspend function.

// The actual Kotlin stdlib interface:
interface Continuation<in T> {
    val context: CoroutineContext          // which thread to resume on
    fun resumeWith(result: Result<T>)      // call this to wake the coroutine
}

// What YOU write:              What COMPILER generates:
suspend fun fetchUser(): User  →  fun fetchUser(cont: Continuation<User>): Any

The chain of continuations

launch { } creates root Continuation

The coroutine builder creates the first continuation from scratch. This is the starting point of the chain.

Hits suspension point → Continuation updated

label set to next case, local variables saved, continuation passed into callee.

Coroutine suspends → thread released

The Continuation object lives on the heap. No thread needed — just a few hundred bytes.

Result arrives → resumeWith() called

The callback fires, the result is passed back, the coroutine jumps to the saved label.

Continues from where it left off

All local variables restored, execution proceeds from the next line.

Why regular functions can't pass a Continuation

A regular function was never transformed into a state machine, so it has no Continuation object. When you call a suspend function, the compiler looks for a Continuation to pass — there isn't one — compiler error.

Thread stackContinuation
Where it livesOS-managed memoryJVM heap (just an object)
Cost~1MB per threadA few hundred bytes
BlockingHolds the threadHolds nothing

This is why you can have 100,000 coroutines simultaneously but only a handful of threads — each paused coroutine is just a tiny Continuation object on the heap.

Coroutine Builders

Builders are regular functions that create a root Continuation and start a coroutine. They bridge the gap between the regular world and the coroutine world.

launch { }

Fire and forget. Returns a Job. No result. Doesn't block the caller.

async { }

Returns a Deferred<T> — a Job with a result. Use .await() to get the value.

runBlocking { }

Blocks the current thread until done. Returns T directly. For tests and main().

coroutineScope { }

A suspend function. Groups children and waits for all to finish. Returns T.

BuilderReturnsIs suspend?Blocks thread?
launchJobNoNo
asyncDeferred<T> (a Job)NoNo
runBlockingTNoYes
coroutineScopeTYesNo

Deferred extends Job — everything Job can do (cancel, join, isActive), Deferred can too, plus .await().

Job & Cancellation

Job is the lifecycle handle to a coroutine. Without it you'd have no way to control, cancel, or wait for a coroutine.

cancel()

Sends a cancellation signal. Sets isActive = false. Returns immediately — does NOT wait.

join()

Suspends the caller until the job reaches Completed or Cancelled state.

cancelAndJoin()

Sends signal AND waits. The safe way to guarantee the coroutine is fully stopped.

isActive / isCompleted

Query the current state of the coroutine from outside.

cancel() is just a signal

job.cancel()
// coroutine might STILL be running here!
// cancel() sets isActive=false and throws CancellationException
// at the NEXT suspension point — not immediately

job.cancelAndJoin()   // ← this is cancel() + join() combined
proceedWithNextTask()  // guaranteed safe now

The blind spot — after the last suspension point

suspend fun load() {
    val result = api.fetch()   // suspension point — cancellation checked here

    // cancel() arrived AFTER network responded?
    // coroutine is past the last suspension point
    // it will run to completion unless you check manually:

    ensureActive()            // ← throws CancellationException if cancelled
    _state.value = result      // ← now safe
}

ensureActive() is just: if (!isActive) throw CancellationException() — one if check, that's all.

When does cancelAndJoin() matter?

ScenarioUse
Shared state / DB writecancelAndJoin()
UI state updates (search)cancelAndJoin()
Exclusive resource (camera, BT)cancelAndJoin()
Fire-and-forget analyticscancel() alone is fine
Independent prefetchcancel() alone is fine

The question to ask: "If the old coroutine runs 1 extra second after I cancel — does it cause a problem?" Yes → cancelAndJoin(). No → cancel() is fine.

Job lifecycle

New Active Completing Completed
Active Cancelling Cancelled

Coroutine Context

CoroutineContext is a bag of configuration that every coroutine carries. It defines how and where the coroutine runs.

Dispatcher

Which thread to run on. Main, IO, Default, Unconfined.

Job

Lifecycle handle — cancel, join, parent-child relationship.

CoroutineName

Debug label — shows in logs and the coroutines debugger.

ExceptionHandler

Catches unhandled exceptions — last line of defence.

// combine with +
val ctx = Dispatchers.IO +
           CoroutineName("sync") +
           CoroutineExceptionHandler { _, e -> log(e) }

scope.launch(ctx) { ... }

// adding same type replaces the old one
Dispatchers.IO + Dispatchers.Main   // only Main survives

How context flows to children

scope.launch(Dispatchers.IO) {
    // context: IO + its own Job (child of scope's Job)

    launch {
        // inherits IO + its OWN new Job (linked to parent)
    }

    launch(Dispatchers.Main) {
        // overrides dispatcher: Main + its OWN new Job
    }
}

Coroutine Scope

CoroutineScope is a wrapper around a CoroutineContext. It's the container that owns coroutines — when the scope is cancelled, all its coroutines are cancelled.

// this is literally all CoroutineScope is:
interface CoroutineScope {
    val coroutineContext: CoroutineContext
}

CoroutineScope vs coroutineScope

CoroutineScope (capital C)coroutineScope (lowercase c)
What it isA class / interfaceA suspend function
Creates new Job?Yes — its own root JobNo — inherits parent's Job
Is suspend?No — regular functionYes
Waits for children?No — returns immediatelyYes — suspends until all done
LifetimeYou manage with cancel()Auto-cleaned when block ends
Use forLong-lived scope (ViewModel, Repo)Parallel work in a suspend fun
// CoroutineScope — you build it, you cancel it
class UserRepository {
    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    fun fetch() = scope.launch { api.getUser() }
    fun destroy() = scope.cancel()   // must call this!
}

// coroutineScope — parallel work, waits for all
suspend fun loadDashboard(): Dashboard = coroutineScope {
    val user  = async { fetchUser() }
    val posts = async { fetchPosts() }
    Dashboard(user.await(), posts.await())
}

Job vs SupervisorJob

Job()SupervisorJob()
One child failsAll siblings cancelled, parent cancelledOnly that child fails — others continue
Use forRelated tasks — fail togetherIndependent tasks — isolate failures
ExampleMulti-step transactionviewModelScope — one screen failing won't kill others

Built-in scopes in Android

ScopeCancelled when
viewModelScopeViewModel.onCleared()
lifecycleScopeLifecycle is destroyed
GlobalScopeNever — avoid!
CoroutineScope()You call scope.cancel()

Wrapping Callbacks

Old callback-based APIs can be wrapped into suspend functions using suspendCancellableCoroutine. The caller sees a clean suspend function — no callbacks visible.

!

Always use suspendCancellableCoroutine, not suspendCoroutine

The cancellable version safely ignores resume() calls after cancellation, and lets you clean up with invokeOnCancellation { }.

suspend fun fetchUser(id: String): User =
    suspendCancellableCoroutine { cont ->

        val call = api.fetchUser(id,
            onSuccess = { user ->
                cont.resume(user)                  // success
            },
            onError = { e ->
                cont.resumeWithException(e)          // failure
            }
        )

        cont.invokeOnCancellation {
            call.cancel()                          // cleanup on cancel
        }
    }

Three scenarios

A

Normal success

Callback fires → cont.resume(value) → coroutine resumes. invokeOnCancellation never fires.

B

Cancelled before callback fires

job.cancel() → invokeOnCancellation fires → cleanup runs → CancellationException thrown into coroutine. Callback may still fire later but cont.resume() is safely ignored.

C

Cancelled after callback fires

Callback fires → cont.resume() → coroutine resumes normally → cancel() arrives too late. Handle with ensureActive() after the suspension point if needed.

Key safety guarantee

cont.resume() after cancellation: suspendCancellableCoroutine safely ignores it. suspendCoroutine would throw IllegalStateException — double resume crash.

Real example — OkHttp

suspend fun get(url: String): String =
    suspendCancellableCoroutine { cont ->
        val call = client.newCall(Request.Builder().url(url).build())

        call.enqueue(object : Callback {
            override fun onResponse(call: Call, response: Response) =
                cont.resume(response.body!!.string())
            override fun onFailure(call: Call, e: IOException) =
                cont.resumeWithException(e)
        })

        cont.invokeOnCancellation { call.cancel() }
    }

The Mental Model

1

Every suspend fun call = suspension point

The compiler cuts the state machine there. Cancellation is checked there. Thread is released there.

2

Continuation = the bookmark

Holds where to resume (label) and how to resume (resumeWith callback). Passed down through every suspend call.

3

cancel() = signal only

Sets isActive=false, throws CancellationException at next suspension point. Does not wait. Use cancelAndJoin() to wait.

4

After last suspension point = blind spot

Regular code after the last suspend call won't catch cancellation automatically. Use ensureActive() if needed.

5

Scope owns coroutines

When scope is cancelled, all children are cancelled. If you create a scope, you cancel it.