Coroutine Scope and Dispatchers

Every coroutine in Kotlin needs two things to run: a CoroutineScope that defines how long it lives and how it gets cancelled, and a CoroutineDispatcher that decides which thread, or pool of threads, actually executes its code. Get the scope wrong and coroutines outlive the object that should own them; get the dispatcher wrong and you either block a thread you shouldn’t (like a UI thread) or waste resources you didn’t need to spend. This lesson explains both mechanisms in depth: how scopes implement structured concurrency, what each built-in dispatcher is for, and how to combine them correctly.

Overview: How Scope and Dispatchers Work

A coroutine never runs on its own — it always runs inside a CoroutineScope. A scope is a small object holding a CoroutineContext, and that context carries at minimum a Job (representing the coroutine’s lifecycle: running, cancelling, or completed) and usually a CoroutineDispatcher. Builder functions like launch and async are extension functions on CoroutineScope, which is exactly why you can’t call them from anywhere — you need a scope to call them on.

This is Kotlin’s structured concurrency model: every coroutine you launch becomes a child of the scope, and transitively of the coroutine, that launched it. Cancel the parent scope and every child cancels too, recursively — no orphaned background work is left running. If a child throws an uncaught exception, by default it propagates up and cancels the parent and its siblings as well, unless the parent’s job is a SupervisorJob, in which case siblings survive a failing child. Compare this to a raw Thread: a leaked thread keeps running even after the code that started it has lost interest, but a coroutine’s lifetime is always tied to something you can reason about and cancel on purpose.

The CoroutineDispatcher is the other half of the picture — it decides which thread (or pool of threads) executes the coroutine’s code each time it resumes. Kotlin ships four standard dispatchers under kotlinx.coroutines.Dispatchers:

Dispatcher Backed by Use it for
Dispatchers.Default A shared thread pool sized to the number of CPU cores CPU-heavy work: sorting, parsing, computation
Dispatchers.IO A larger, elastic thread pool that can grow well beyond core count Blocking calls: file I/O, JDBC, blocking network clients
Dispatchers.Main A single, specific UI thread Touching UI state on Android, Swing, or JavaFX
Dispatchers.Unconfined No pool of its own — starts on the caller’s thread Rare; mostly advanced library internals and tests

The crucial thing to understand: a dispatcher does not give a coroutine its own thread the way Thread() does. It schedules the coroutine’s code onto a shared pool. When a coroutine hits a suspension point — a call to a suspend function such as delay, or a suspending network call — it hands the underlying thread back to the pool instead of blocking it. The thread is then free to run other coroutines while the first one waits. When the suspended coroutine is ready to continue, the dispatcher schedules it, possibly on a different thread from the same pool, to resume. That is why one machine can run tens of thousands of concurrently-suspended coroutines on a pool of only a few dozen real OS threads, while creating tens of thousands of raw Thread objects (each with its own OS stack, roughly a megabyte by default) would exhaust memory and scheduling long before that.

runBlocking is a special bridge: it creates a coroutine scope and blocks the calling thread until that coroutine, and all its children, complete. It exists for places that are not already coroutines — fun main(), unit tests — so they can call suspending code. It should almost never appear inside application code that already runs inside a coroutine, because blocking a thread defeats the entire point of using dispatchers in the first place.

Syntax

The general shape of creating a scope, launching work on a dispatcher, and shutting the scope down looks like this:

val scope = CoroutineScope(Dispatchers.Default + Job())

scope.launch(Dispatchers.IO) {
    // suspending code is fine here; this pool is built for blocking work
}

scope.launch(Dispatchers.Main) {
    // update UI state here; never do blocking I/O in this block
}

scope.cancel()
  • CoroutineScope(context) — builds a new, independent scope from a CoroutineContext; typically combines a Job or SupervisorJob with a default dispatcher.
  • scope.launch(dispatcher) { ... } — starts a coroutine that returns no result (a Job); runs on the given dispatcher, or the scope’s own dispatcher if none is passed.
  • scope.async(dispatcher) { ... } — like launch, but returns a Deferred<T> whose result you retrieve with await().
  • coroutineScope { ... } / supervisorScope { ... } — suspend functions that open a child scope tied to the calling coroutine; they suspend until every child inside finishes, and are the idiomatic way to group concurrent work inside a suspend fun without creating a brand-new top-level scope.
  • scope.cancel() — cancels the scope’s job and every coroutine launched inside it.

Examples

Example 1: launching on Dispatchers.Default from runBlocking.

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Main starts on ${Thread.currentThread().name}")
    launch(Dispatchers.Default) {
        println("Child starts on ${Thread.currentThread().name}")
        delay(100)
        println("Child resumes on ${Thread.currentThread().name}")
    }
    println("Main continues immediately, before the child finishes")
}

Output:

Main starts on main
Main continues immediately, before the child finishes
Child starts on DefaultDispatcher-worker-1
Child resumes on DefaultDispatcher-worker-1

launch starts the child coroutine and returns immediately without waiting for it, so "Main continues immediately" prints before the child gets a chance to run. The exact worker number in DefaultDispatcher-worker-1 can vary between runs, but the relative ordering shown here is guaranteed. runBlocking does not let main() exit until every child it launched has completed, so both of the child’s lines are guaranteed to print before the program ends, even though they print after main‘s own last line.

Example 2: concurrent results with async, await, and coroutineScope.

import kotlinx.coroutines.*

suspend fun fetchUser(id: Int): String = withContext(Dispatchers.IO) {
    delay(200)
    "User$id"
}

fun main() = runBlocking {
    val combined = coroutineScope {
        val first = async { fetchUser(1) }
        val second = async { fetchUser(2) }
        "${first.await()} and ${second.await()}"
    }
    println(combined)
}

Output:

User1 and User2

fetchUser hops onto Dispatchers.IO with withContext, which is the correct dispatcher for a function that (in real code) would perform blocking I/O. Both calls to fetchUser start essentially at the same time via async, so the 200ms delays overlap instead of stacking — the whole block takes roughly 200ms, not 400ms, which is the entire point of using async/await for independent work instead of calling two suspend functions back to back.

Example 3: what dispatchers abstract away. This one uses only the Kotlin standard library’s raw threads, to make the underlying cost concrete before you rely on a dispatcher to hide it.

import kotlin.concurrent.thread

fun main() {
    println("main() starts on ${Thread.currentThread().name}")

    val workerA = thread(name = "worker-A") {
        println("workerA running on ${Thread.currentThread().name}")
    }
    workerA.join()

    val workerB = thread(name = "worker-B") {
        println("workerB running on ${Thread.currentThread().name}")
    }
    workerB.join()

    println("main() finished")
}

Output:

main() starts on main
workerA running on worker-A
workerB running on worker-B
main() finished

Each call to kotlin.concurrent.thread spins up a genuine OS thread with its own stack, and .join() blocks until it finishes. If workerA and workerB represented two independent network calls, this is what raw threads would cost you: one real thread reserved per unit of concurrent work. A dispatcher-backed coroutine doing the same two calls with async could run both on one or two pooled threads instead, because suspension frees the thread between the request going out and the response coming back.

How It Works Step by Step

Tracing Example 2’s execution: (1) main() calls runBlocking, which creates a root coroutine and blocks the JVM’s main thread until that coroutine finishes. (2) Inside it, coroutineScope { ... } opens a child scope tied to the current coroutine and suspends the caller until every coroutine launched inside it completes. (3) async { fetchUser(1) } starts immediately; fetchUser calls withContext(Dispatchers.IO), hopping execution onto the IO pool and starting its 200ms delay. (4) async { fetchUser(2) } starts right after, likewise hopping to the IO pool and starting its own 200ms delay concurrently with the first. (5) first.await() and second.await() suspend until each Deferred completes; because both delays run in parallel, the combined wait is about 200ms rather than 400ms. (6) Once both children finish, coroutineScope returns the combined string. (7) println prints it. (8) The outer coroutine completes, runBlocking unblocks the main thread, and the program exits.

Common Mistakes

Mistake 1: launching on GlobalScope instead of an owned scope. GlobalScope is tied to the whole application’s lifetime, not to any object you control, so work launched on it can’t be cancelled when the thing that started it (a screen, a request, a repository) goes away — it silently keeps running and can touch destroyed state.

class UserRepository {
    fun loadUser(id: Int) {
        GlobalScope.launch {
            val user = fetchUser(id)
            println(user)
        }
    }
}
class UserRepository {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    fun loadUser(id: Int) {
        scope.launch {
            val user = fetchUser(id)
            println(user)
        }
    }

    fun close() {
        scope.cancel()
    }
}

Mistake 2: running blocking code directly on Dispatchers.Main. The main dispatcher is a single UI thread; any blocking call executed on it — file I/O, a blocking HTTP client, a long computation — freezes the UI until it returns.

fun refreshScreen() = runBlocking(Dispatchers.Main) {
    val text = File("report.txt").readText()
    println(text)
}
fun refreshScreen() = runBlocking(Dispatchers.Main) {
    val text = withContext(Dispatchers.IO) {
        File("report.txt").readText()
    }
    println(text)
}

The fix wraps only the blocking call in withContext(Dispatchers.IO), which suspends the coroutine, frees the main thread while the file read happens on the IO pool, and resumes back on Dispatchers.Main automatically once the read finishes.

Best Practices

  • Tie every scope to a real lifecycle you control (a repository, a view model, a request handler) and cancel it explicitly when that owner is done; never reach for GlobalScope.
  • Prefer coroutineScope / supervisorScope inside suspend functions over manufacturing a new top-level scope just to group concurrent work.
  • Use Dispatchers.IO for blocking calls and Dispatchers.Default for CPU-bound work; running blocking calls on Default starves it, since that pool is intentionally sized to the number of CPU cores.
  • Use a SupervisorJob (or supervisorScope) when one child’s failure shouldn’t cancel independent siblings, such as loading several unrelated widgets on a dashboard.
  • Reach for withContext to hop dispatchers for a single block of code rather than launching a whole new coroutine just to change threads.
  • Treat runBlocking as a bridge for non-coroutine entry points only — main(), tests — never call it from inside a suspend function or from code that’s already running inside a coroutine.

Practice Exercises

1. Write a suspend fun slowSquare(n: Int): Int that runs on Dispatchers.Default and returns n * n after a short delay. Launch three calls concurrently with async inside a coroutineScope, then print all three results. Hint: collect the three Deferred<Int> values in a list and call awaitAll() on them.

2. Take the corrected UserRepository from Common Mistakes and add a second function, loadOrders(id: Int), that also launches on scope. Call close() and confirm (in your own reasoning, or with a print statement placed after a delay) that no further output from either function appears once the scope is cancelled.

3. In Example 2, explain why swapping Dispatchers.IO for Dispatchers.Default inside fetchUser would be a mistake if the delay call were replaced with a real blocking network request instead of a suspending one.

Summary

  • A CoroutineScope holds a Job that defines a coroutine’s lifetime and enables structured concurrency: cancel the parent, and every child cancels with it.
  • launch and async are builder functions on CoroutineScope; async additionally returns a Deferred<T> retrieved with await().
  • A CoroutineDispatcher chooses which thread pool runs a coroutine’s code; suspension hands the thread back to the pool instead of blocking it, which is why many coroutines can share very few real threads.
  • Use Dispatchers.Default for CPU work, Dispatchers.IO for blocking calls, Dispatchers.Main for UI-thread work, and avoid Dispatchers.Unconfined unless you specifically need its caller-thread behavior.
  • coroutineScope and supervisorScope create child scopes tied to the calling coroutine — the idiomatic way to group concurrent work without spinning up an independent top-level scope.
  • Never use GlobalScope in application code; tie scopes to a real, cancellable lifecycle you control.