Kotlin
Coroutines
Explained
Everything from suspend functions to continuations, state machines, cancellation, and wrapping callbacks — built from first principles.
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 | ✓ Yes | No |
| 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
| Call | Why 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 type | Dispatcher |
|---|---|
| CPU heavy (sorting, parsing) | Dispatchers.Default |
| File / network / database | Dispatchers.IO |
| UI updates | Dispatchers.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 stack | Continuation | |
|---|---|---|
| Where it lives | OS-managed memory | JVM heap (just an object) |
| Cost | ~1MB per thread | A few hundred bytes |
| Blocking | Holds the thread | Holds 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.
| Builder | Returns | Is suspend? | Blocks thread? |
|---|---|---|---|
| launch | Job | No | No |
| async | Deferred<T> (a Job) | No | No |
| runBlocking | T | No | Yes |
| coroutineScope | T | Yes | No |
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?
| Scenario | Use |
|---|---|
| Shared state / DB write | cancelAndJoin() |
| UI state updates (search) | cancelAndJoin() |
| Exclusive resource (camera, BT) | cancelAndJoin() |
| Fire-and-forget analytics | cancel() alone is fine |
| Independent prefetch | cancel() 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
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 is | A class / interface | A suspend function |
| Creates new Job? | Yes — its own root Job | No — inherits parent's Job |
| Is suspend? | No — regular function | Yes |
| Waits for children? | No — returns immediately | Yes — suspends until all done |
| Lifetime | You manage with cancel() | Auto-cleaned when block ends |
| Use for | Long-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 fails | All siblings cancelled, parent cancelled | Only that child fails — others continue |
| Use for | Related tasks — fail together | Independent tasks — isolate failures |
| Example | Multi-step transaction | viewModelScope — one screen failing won't kill others |
Built-in scopes in Android
| Scope | Cancelled when |
|---|---|
| viewModelScope | ViewModel.onCleared() |
| lifecycleScope | Lifecycle is destroyed |
| GlobalScope | Never — 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
Normal success
Callback fires → cont.resume(value) → coroutine resumes. invokeOnCancellation never fires.
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.
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
Every suspend fun call = suspension point
The compiler cuts the state machine there. Cancellation is checked there. Thread is released there.
Continuation = the bookmark
Holds where to resume (label) and how to resume (resumeWith callback). Passed down through every suspend call.
cancel() = signal only
Sets isActive=false, throws CancellationException at next suspension point. Does not wait. Use cancelAndJoin() to wait.
After last suspension point = blind spot
Regular code after the last suspend call won't catch cancellation automatically. Use ensureActive() if needed.
Scope owns coroutines
When scope is cancelled, all children are cancelled. If you create a scope, you cancel it.