Coroutines

suspend functions, structured concurrency, dispatchers, cancellation, and the concurrency model that replaces callback hell.

suspend launch / async Structured concurrency Dispatchers Cancellation Channels Flow
โธ

suspend Functions

A suspend function can pause its execution at a well-defined point and resume later, without blocking the thread it started on. The compiler rewrites it using the continuation-passing style under the hood, but you write it like ordinary sequential code โ€” no callbacks, no .then() chains. Marking a function suspend is a compile-time contract: it can only be called from another suspend function or from inside a coroutine, and the compiler enforces that boundary so you can never accidentally call a suspending function from plain synchronous code.

Sequential code that doesn't block a thread suspend fun
// suspend marks a function as a potential pause point
suspend fun fetchUser(id: Int): User {
    delay(1000)          // suspends here โ€” frees the thread, doesn't sleep it
    return User(id, "Ada")
}

suspend fun fetchUserWithPosts(id: Int): Pair<User, List<Post>> {
    // Reads top to bottom like ordinary blocking code โ€”
    // no nested callbacks despite each call suspending
    val user = fetchUser(id)
    val posts = fetchPosts(user.id)
    return user to posts
}

// fetchUser(1)  // ERROR from plain code: suspend fun requires a
//               // suspend caller or a coroutine to run in
๐Ÿ’ก
"Suspends, doesn't block" is the core distinction. Blocking a thread means the OS keeps that thread parked, unable to do anything else, for the whole wait. Suspending means the coroutine gives the thread back to the pool to run other work, and picks up again โ€” possibly on a different thread โ€” once the awaited operation completes. This is what lets thousands of coroutines share a handful of real OS threads.
๐Ÿน
Coming from Go: a goroutine is a real (if lightweight) unit the Go runtime schedules onto OS threads automatically, with no keyword marking a function as "goroutine-safe" โ€” any function can run inside one. Kotlin's coroutines instead require the compiler's cooperation: only functions marked suspend can pause, and the compiler transforms them at compile time. It's a more explicit, more restricted mechanism than Go's, in exchange for the compiler being able to statically enforce where suspension can and can't happen.
๐Ÿš€

launch vs async

Both launch and async start a new coroutine, and both are ordinary functions, not language keywords โ€” they're extension functions on CoroutineScope from the kotlinx.coroutines library. They differ in what they hand back: launch returns a Job for when you only care about the side effect and completion/cancellation, async returns a Deferred<T> for when you need a result back, retrieved by calling the suspending await().

launch: fire-and-manage, no return value launch
fun main() = runBlocking {
    // launch starts a coroutine and returns a Job immediately;
    // the caller doesn't wait for it unless it explicitly joins
    val job = launch {
        delay(500)
        println("background work done")
    }
    println("launched, not waiting yet")
    job.join()  // suspend until the launched coroutine finishes
    println("job joined")
}
// launched, not waiting yet
// background work done
// job joined
async: concurrent, awaitable result async / await
suspend fun fetchUser(): User { delay(1000); return User(1, "Ada") }
suspend fun fetchPosts(): List<Post> { delay(1000); return listOf() }

suspend fun loadDashboard() = coroutineScope {
    // Both start immediately and run concurrently โ€”
    // this takes ~1000ms total, not 2000ms
    val userDeferred = async { fetchUser() }
    val postsDeferred = async { fetchPosts() }

    // await() suspends until each result is ready
    val user = userDeferred.await()
    val posts = postsDeferred.await()
    "${user.name}: ${posts.size} posts"
}
๐Ÿ’ก
Reach for async only when you genuinely need concurrent results, and start both async calls before calling await() on either โ€” calling .await() immediately after each individual async serializes them and defeats the purpose. For a single sequential suspending call with no concurrency need, just call the suspend function directly; wrapping it in async { }.await() adds coroutine overhead for no benefit.
๐ŸŒณ

Structured Concurrency & Scopes

Structured concurrency is the rule that every coroutine is launched inside a CoroutineScope, and a scope cannot complete until all the coroutines launched inside it have completed. Children form a tree rooted at the scope: cancel the parent, and every descendant is cancelled with it; let a child throw an unhandled exception, and by default it propagates up and cancels its siblings too. This is the mechanism that makes "a coroutine that outlives its intended caller" structurally difficult instead of a manual bookkeeping exercise.

  coroutineScope {                 โ† parent scope
      launch { taskA() }           โ† child 1
      launch {                     โ† child 2
          launch { taskB() }       โ† grandchild, still tracked by the tree
      }
  }
  // this line only runs after EVERY coroutine in the tree completes

  Cancelling the parent job cancels every child and grandchild.
  An uncaught exception in any child cancels its siblings and
  propagates to the parent (unless a SupervisorJob changes that).
coroutineScope: waits for every child coroutineScope
suspend fun loadEverything() = coroutineScope {
    launch { delay(100); println("task A done") }
    launch { delay(200); println("task B done") }
    println("both launched")
    // coroutineScope suspends here until BOTH launched children finish โ€”
    // this is what "structured" means: no dangling background work
}
// both launched
// task A done
// task B done
// loadEverything() only returns after this line
โš ๏ธ
GlobalScope.launch { } deliberately breaks structured concurrency โ€” it launches a coroutine tied to nothing, with no parent to cancel it and no scope waiting for it to finish. It's almost always the wrong choice in application code; a coroutine started this way can outlive the screen, request, or component that started it, silently leaking work and resources. Scope every coroutine to something with a well-defined lifetime instead.
๐Ÿน
Coming from Go: Go has no built-in equivalent โ€” a goroutine you go func() { ... }() is unstructured by default, and it's entirely on you to track its lifetime with a sync.WaitGroup or a cancellation channel. Structured concurrency is Kotlin baking that discipline into the type system: you cannot launch a coroutine without a scope to hold it, the same way context.Context cancellation propagation is a Go convention rather than something the compiler enforces.
๐Ÿงต

Dispatchers

A dispatcher decides which thread (or thread pool) a coroutine actually runs on. Coroutines aren't tied to one thread for their whole lifetime the way a traditional thread is โ€” a coroutine can suspend on one thread and resume on another, and the dispatcher is what controls that assignment. Choosing the right dispatcher is mostly about not blocking the wrong pool: don't run CPU-bound work on the thread pool meant for blocking I/O, and vice versa.

The standard dispatchers Dispatchers
suspend fun example() = coroutineScope {
    // Dispatchers.Default: CPU-bound work โ€” sized to the number of cores
    launch(Dispatchers.Default) {
        heavyComputation()
    }

    // Dispatchers.IO: blocking calls โ€” file access, JDBC, blocking HTTP โ€”
    // backed by a much larger, elastic thread pool built for waiting
    launch(Dispatchers.IO) {
        readLargeFile()
    }

    // Dispatchers.Main: the UI thread (Android, Swing, JavaFX) โ€”
    // unavailable in plain JVM console apps without a UI framework
    launch(Dispatchers.Main) {
        updateUi()
    }
}
๐Ÿ’ก
A blocking call (plain JDBC, Thread.sleep, blocking file I/O) run on Dispatchers.Default or inside a coroutine with no dispatcher switch is a common real-world bug: it parks one of the few CPU-bound threads on I/O wait, starving other CPU-bound coroutines that are actually ready to run. Move blocking calls to Dispatchers.IO, which is specifically sized to tolerate many parked threads.
๐Ÿ”

withContext

withContext switches the current coroutine to a different dispatcher for the duration of a block, then switches back โ€” without launching a new coroutine or a new job. This is the idiomatic way to move a single suspending operation onto the right dispatcher, in contrast to launch(dispatcher), which starts an entirely separate concurrent coroutine.

Moving one call to the right dispatcher withContext
suspend fun loadUserName(id: Int): String {
    // withContext suspends the CURRENT coroutine, runs the block on
    // Dispatchers.IO, then resumes back on the original dispatcher โ€”
    // no new coroutine, no new Job, just a temporary context switch
    val user = withContext(Dispatchers.IO) {
        database.query("SELECT name FROM users WHERE id = $id")
    }
    return user.name
    // execution here is back on whatever dispatcher called loadUserName
}
โ„น๏ธ
Because withContext doesn't start a new coroutine, it doesn't add anything to the structured-concurrency tree โ€” it's purely a dispatcher hop within the same logical coroutine. That's the key distinction from launch(Dispatchers.IO) { }: the latter creates a genuinely separate, concurrently-running child.
๐Ÿ›‘

Cancellation & Cooperation

Cancellation in coroutines is cooperative, not preemptive โ€” cancelling a Job doesn't forcibly kill a thread mid-instruction the way a hard kill would. It sets a flag and throws a special CancellationException at the next suspension point the coroutine reaches. This means a coroutine running a tight, non-suspending computation loop won't notice it's been cancelled until it either suspends or explicitly checks.

Suspending functions check cancellation automatically Cooperative
suspend fun example() = coroutineScope {
    val job = launch {
        repeat(1000) { i ->
            println("working: $i")
            delay(100)  // a suspension point โ€” checks for cancellation here
        }
    }
    delay(350)
    job.cancel()  // the loop stops around iteration 3, at the next delay()
}
A CPU-bound loop needs a manual check isActive
suspend fun example() = coroutineScope {
    val job = launch(Dispatchers.Default) {
        var i = 0
        // No suspending call in this loop, so cancellation is never
        // noticed unless checked explicitly โ€” this would spin forever
        // on cancel() without the isActive check
        while (isActive) {
            i++
            if (i % 100_000_000 == 0) println("still computing")
        }
    }
    delay(500)
    job.cancelAndJoin()  // cancel, then suspend until it's actually finished
}
โš ๏ธ
Never blanket-catch and swallow exceptions inside a coroutine with a bare catch (e: Exception). CancellationException is how cooperative cancellation propagates โ€” silently swallowing it prevents the coroutine (and its cleanup) from ever actually stopping. Catch specific exception types, or re-throw CancellationException if you must catch broadly.
๐Ÿšจ

Exception Handling

An uncaught exception inside a regular coroutineScope child cancels the whole scope: siblings included. That's structured concurrency working as intended โ€” one failed unit of work shouldn't leave the others in an undefined state. Sometimes that's not what you want, though: a UI with five independent widgets shouldn't all crash because one network call failed. SupervisorJob is the escape hatch, isolating failures to the child that threw.

Default: one failure cancels all siblings coroutineScope
suspend fun example() {
    try {
        coroutineScope {
            launch { delay(100); throw RuntimeException("task A failed") }
            launch { delay(500); println("task B: never prints") }
        }
    } catch (e: RuntimeException) {
        println("caught: ${e.message}")  // task B was cancelled before it could finish
    }
}
SupervisorJob: isolate failures per child supervisorScope
suspend fun example() = supervisorScope {
    val jobA = launch {
        delay(100)
        throw RuntimeException("task A failed")
    }
    val jobB = launch {
        delay(500)
        println("task B: still runs to completion")  // unaffected by A's failure
    }
}
// task A's exception must still be handled โ€” e.g. via a
// CoroutineExceptionHandler installed on the scope โ€” or it
// propagates to the thread's default uncaught exception handler
๐Ÿ’ก
Wrap risky code in an ordinary try/catch around the suspending call itself when you can โ€” it's the clearest and most predictable option. Reach for SupervisorJob/supervisorScope specifically when you need independent children that shouldn't cancel each other, and a CoroutineExceptionHandler as a last-resort catch-all for exceptions that escape a top-level launch (it has no effect on async, whose exceptions surface through await() instead).
๐Ÿ“ก

Channels

A Channel<T> is a coroutine-based pipe for passing values between coroutines, conceptually close to a blocking queue but built around suspension instead of thread blocking: send suspends when the channel is full, receive suspends when it's empty. Channels model hot, one-shot streams of events between concurrently running coroutines โ€” a producer-consumer pipeline is the classic use case.

Producer-consumer with a Channel Channel<T>
suspend fun example() = coroutineScope {
    val channel = Channel<Int>(capacity = 2)

    launch {
        for (i in 1..5) {
            channel.send(i)   // suspends once the buffer of 2 fills up
            println("sent $i")
        }
        channel.close()   // signals no more values are coming
    }

    launch {
        // for-in over a channel receives until it's closed and drained
        for (value in channel) {
            delay(100)
            println("received $value")
        }
    }
}
๐Ÿน
Coming from Go: a Kotlin Channel maps almost directly onto a Go chan โ€” buffered or unbuffered, send/receive mirroring ch <- v/<-ch, and closing signals completion the same way. The difference is scope: a Go channel is a standalone primitive usable from any goroutine, while a Kotlin Channel is typically created and consumed within a structured coroutine hierarchy, and in modern Kotlin code, Flow often replaces channels for the common "stream of values" case, reserving raw channels for genuine producer-consumer coordination.
๐ŸŒ€

Flow: a First Look

A Flow<T> is an asynchronous, cold stream of values โ€” cold meaning the producer block doesn't run at all until something calls collect on it, and runs again from scratch for every new collector. Where a single suspend function returns one value, a Flow returns zero or more values over time, each one delivered through suspension rather than blocking. This is only the shape of it; the full operator set, StateFlow/SharedFlow, and context handling live in the dedicated Flow guide.

Building and collecting a Flow flow { } / collect
fun countdown(from: Int): Flow<Int> = flow {
    for (i in from downTo 1) {
        delay(100)   // each emission can suspend
        emit(i)         // pushes a value to the collector
    }
}

suspend fun main() {
    countdown(3).collect { value ->
        println(value)
    }
}
// 3
// 2
// 1
๐Ÿ“‹

Quick Reference

Concept Syntax Notes
Suspending functionsuspend fun f()Callable only from suspend code or a coroutine
Fire-and-managelaunch { ... }Returns a Job; no result value
Concurrent resultasync { ... }Returns a Deferred<T>; get the value with await()
Wait for a Jobjob.join()Suspends until the coroutine completes
Structured scopecoroutineScope { ... }Suspends until every child completes
Breaks structureGlobalScope.launch { ... }No parent; avoid in application code
CPU-bound dispatcherDispatchers.DefaultSized to CPU core count
Blocking I/O dispatcherDispatchers.IOLarge, elastic pool for blocking calls
Switch dispatcherwithContext(Dispatchers.IO) { }Same coroutine, temporary dispatcher hop
Cancel a Jobjob.cancel()Cooperative: takes effect at the next suspension point
Manual cancellation checkwhile (isActive) { }Needed in loops with no suspending call
Cancel and waitjob.cancelAndJoin()Cancels, then suspends until fully stopped
Isolate child failuressupervisorScope { }One child's exception doesn't cancel its siblings
Coroutine pipeChannel<T>()send/receive, suspension-based producer-consumer
Cold async streamflow { emit(x) }Runs only when collected; see the Flow guide