Dispatchers & Threading Explained
KOTLIN › Coroutines
A CoroutineDispatcher is the part of the machinery that answers one question: which thread actually runs this code? Everything else about coroutines, suspending, resuming, structured concurrency, is thread-agnostic. The dispatcher is where threads enter the picture.
Mechanically a dispatcher is close to an Executor. When a coroutine is ready to run, either at launch or after a suspension point resolves, the dispatcher receives a block of work and decides where to put it:
// This is roughly the whole interface
public abstract class CoroutineDispatcher : ... {
public abstract fun dispatch(context: CoroutineContext, block: Runnable)
}
The important consequence: a dispatcher acts at **resumption points**, not continuously. A coroutine on Dispatchers.IO is not "held" by an IO thread while it is suspended. It occupies a thread only while it is executing, and when it suspends the thread goes back to the pool for someone else to use. That is why a hundred coroutines waiting on network calls do not need a hundred threads.
Get this straight and the rest of the topic is consequences. Choosing a dispatcher is choosing which pool your code competes for time on, and withContext is how you move between them.
A CoroutineDispatcher decides which thread, or thread pool, actually runs a coroutine's code. Picking the right one is a constant interview probe, and the answer depends entirely on what kind of work you're doing.
- **Dispatchers.Main**: UI updates and anything touching views. Must stay fast, nothing blocking runs here. - **Dispatchers.IO**: blocking I/O, network calls, disk reads, database queries. Backed by a large, elastic pool built for lots of concurrently-blocked calls. - **Dispatchers.Default**: CPU-bound work, sorting, parsing, JSON processing. Its pool is sized to your device's CPU core count, so it doesn't help to throw more concurrent work at it than that.
Matching the dispatcher to the work matters: running blocking I/O on Default starves a pool sized for computation, and running CPU-heavy work on IO wastes a pool built for waiting, not computing.
That three-way split hides a detail interviewers like: **IO and Default are not two separate thread pools.** In modern kotlinx.coroutines they share one pool, and what differs is how many threads each is permitted to occupy at once.
- Dispatchers.Default is capped at the number of CPU cores, with a floor of 2. More parallel CPU work than you have cores buys you nothing but context switching. - Dispatchers.IO is capped at 64 threads (or the core count, whichever is larger), and it can grow the shared pool beyond Default's limit to reach that cap.
The 64 is the point. Blocked threads are not doing work, they are waiting, so limiting them to core count would throttle you for no reason. A phone with 8 cores can still have 64 outstanding blocking calls in flight.
// Fine: 50 blocking calls, the pool grows to serve them
coroutineScope {
repeat(50) { launch(Dispatchers.IO) { blockingHttpCall() } }
}
Because they share a pool, withContext(Dispatchers.IO) from inside Default is cheap: there is often no thread handoff at all, just a change in accounting. And the sharp edge: the 64 cap is per-application, not per-call-site. Sixty-five concurrent blocking database writes will queue behind each other no matter how many coroutines you launch.
Sometimes you want a narrower limit than "up to 64". The modern tool is **limitedParallelism**, which produces a view onto an existing dispatcher that will never run more than N coroutines at a time:
class DatabaseClient {
// At most 4 concurrent writes, no matter how many callers there are
private val writes = Dispatchers.IO.limitedParallelism(4)
suspend fun write(row: Row) = withContext(writes) { dao.insert(row) }
}
This replaces the old habit of creating a private Executors.newFixedThreadPool(4).asCoroutineDispatcher(). The difference matters: limitedParallelism creates **no new threads**. It borrows from the pool the parent dispatcher already owns and simply refuses to dispatch more than N at once. A custom executor means real threads sitting idle and a resource you are obliged to close.
The special case worth memorising is limitedParallelism(1):
private val serial = Dispatchers.Default.limitedParallelism(1)
// Every coroutine using `serial` runs one at a time, in submission order
That gives you a single-threaded-equivalent execution context, which means shared mutable state touched only from inside it needs no lock and no atomic. It is thread confinement without owning a thread, and it is a strong answer when an interviewer asks how to serialise access to something without synchronized.
Dispatchers.Unconfined is the one that confuses people, because its name suggests "runs anywhere" when the real behaviour is more specific: it does not dispatch at all.
launch(Dispatchers.Unconfined) {
println(Thread.currentThread().name) // the caller's thread, immediately
delay(100) // first suspension point
println(Thread.currentThread().name) // whatever thread resumed the delay
}
A normal dispatcher takes the block and schedules it. Unconfined runs it inline on whatever thread called launch, right there, before launch even returns. After the first suspension the coroutine resumes on whichever thread completed the suspending call, so for delay that is a shared scheduler thread, not the original one.
That makes it useless as a general-purpose choice and genuinely useful in two places. In tests, UnconfinedTestDispatcher runs coroutines eagerly so you do not have to advance a scheduler to see effects. And in framework code, it avoids a pointless dispatch when a coroutine only needs to hand a value onward and does not care where it lands.
In application code, reaching for Unconfined almost always means you actually wanted Dispatchers.Main.immediate, which has a defined thread rather than an undefined one.
On Android, Dispatchers.Main is not built into kotlinx-coroutines-core. It is supplied by the kotlinx-coroutines-android artifact, which installs a dispatcher backed by a Handler on the main Looper. Every dispatch is a Handler.post, which is why the main dispatcher inherits the message queue's ordering and fairness.
That leads to the variant worth knowing: **Dispatchers.Main.immediate**.
// Already on the main thread?
withContext(Dispatchers.Main) { textView.text = name }
// -> posts a message, runs on a LATER main-loop pass
withContext(Dispatchers.Main.immediate) { textView.text = name }
// -> already on main, so runs right now with no post
Plain Main always posts, even when you are already on the main thread. Main.immediate checks first, and executes inline if the current thread is already correct. The visible difference is a frame of latency, which is exactly why repeatOnLifecycle and the lifecycle-aware collection helpers use immediate internally: a state update that arrives while you are already on the main thread should render on this frame, not the next one.
The rule of thumb: prefer Main.immediate for UI updates driven by state you are already observing on the main thread, and plain Main when you genuinely want to yield to the message queue first.
withContext is how you switch dispatchers for a block of code and hand the result back, and it's the tool that makes a suspend function main-safe. It suspends, runs its block on the given context, returns the result, and switches back to whatever context called it.
suspend fun readFile(file: File): String =
withContext(Dispatchers.IO) {
file.readText() // blocking I/O, safely off the main thread
} // resumes on the ORIGINAL dispatcher
The caller doesn't need to know or care which dispatcher did the work: call readFile from Dispatchers.Main, and you get the string back on Dispatchers.Main, with no blocking anywhere along the way. That's the whole idea of main-safety, baked into the function itself rather than left for every call site to remember.
The same shape works for any dispatcher, not just IO:
suspend fun <T> runOn(dispatcher: CoroutineDispatcher, block: suspend () -> T): T =
withContext(dispatcher) { block() }
withContext is not free, and knowing its cost separates people who have profiled coroutine code from people who have only read about it.
Each call does real work: it builds a new context, may dispatch onto another thread, suspends the caller, and dispatches back on completion. On a shared pool that can be tens of microseconds. Irrelevant once, meaningful inside a loop:
// Bad: a dispatcher round trip per row
suspend fun save(rows: List<Row>) {
rows.forEach { withContext(Dispatchers.IO) { dao.insert(it) } }
}
// Good: one switch for the whole batch
suspend fun save(rows: List<Row>) = withContext(Dispatchers.IO) {
rows.forEach { dao.insert(it) }
}
There is one optimisation built in: if the target dispatcher is the same one you are already on, withContext skips the dispatch and runs the block without a thread handoff. It still allocates and still suspends, so it is cheaper but not free.
The principle to state in an interview is **push the switch outward**. A suspend function should own its threading requirement once, at the boundary where the work begins, rather than switching repeatedly inside. And a suspend function that does no blocking work at all should not switch dispatchers on principle: it should simply inherit its caller's, staying main-safe by not blocking rather than by relocating.
Every coroutine carries a **CoroutineContext** -- a set of elements that configure how it runs. You've already met one element: the dispatcher. But a context can hold several elements combined with the + operator:
val ctx = Dispatchers.IO + CoroutineName("fetch-user") + exceptionHandler
scope.launch(ctx) { ... }
Each element in the context has a unique **key**. When you combine two contexts with +, the right side wins for any duplicate keys, and non-overlapping keys are preserved. Think of it like merging two maps:
val a = Dispatchers.IO + CoroutineName("worker")
val b = Dispatchers.Default + CoroutineName("reader")
val merged = a + b
// merged dispatcher is Default (right wins)
// merged name is "reader" (right wins)
CoroutineName is especially useful for debugging. It shows up in thread names when the debug agent is on (-Dkotlinx.coroutines.debug), in exception stack traces, and in logging. In a production app with dozens of coroutines running concurrently, giving each one a descriptive name is the difference between "some coroutine crashed" and "fetch-user crashed".
Occasionally you do need a dispatcher backed by threads you control: a native library that demands to be called from one specific thread, or a legacy ExecutorService you are wrapping. The bridge is asCoroutineDispatcher():
private val executor = Executors.newSingleThreadExecutor()
private val nativeDispatcher = executor.asCoroutineDispatcher()
suspend fun decode(frame: Frame) = withContext(nativeDispatcher) {
NativeCodec.decode(frame) // library requires a stable thread
}
fun shutdown() {
nativeDispatcher.close() // or executor.shutdown()
}
Two obligations come with it. First, **you own the lifecycle**: an ExecutorCoroutineDispatcher holds real threads that keep running until closed, and forgetting the close() is a thread leak that survives every screen in your app. Second, it does not participate in the shared pool, so its threads sit idle when unused rather than being borrowed for other work.
Because of both, this should be your last resort. If the goal is "limit concurrency" the answer is limitedParallelism. If the goal is "serialise access to state" the answer is limitedParallelism(1) or a Mutex. A custom executor is justified only by a genuine requirement for specific, stable, application-owned threads.
Dispatchers give you a concurrency strategy that is easy to overlook: **thread confinement**. If all access to a piece of mutable state goes through one dispatcher that runs one thing at a time, that state needs no lock, no atomic, and no volatile.
class EventLog {
private val confined = Dispatchers.Default.limitedParallelism(1)
private val entries = mutableListOf<String>() // plain, unsynchronised
suspend fun add(entry: String) = withContext(confined) {
entries += entry
}
suspend fun snapshot(): List<String> = withContext(confined) {
entries.toList()
}
}
Nothing here is thread-safe in itself. A plain mutableListOf shared across threads is exactly the bug the memory-model topic warns about. It is safe because every path in or out passes through a context that serialises execution, and the dispatch itself creates the happens-before edge that makes one coroutine's writes visible to the next.
The mistake to avoid is a leaky boundary. Return the internal list directly instead of a copy and a caller can mutate it from any thread, and the confinement is gone. Confinement only holds while the state is genuinely unreachable from outside.
Interviewers like this answer because it inverts the usual instinct. The question "how do you make this thread-safe" has a good answer that is not a lock.
The last piece is testing, and it drives a design rule: **never hard-code a dispatcher inside a class**. Take it as a constructor parameter.
class UserRepository(
private val api: Api,
private val io: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun load(id: String): User = withContext(io) { api.fetch(id) }
}
// In a test
val repo = UserRepository(fakeApi, StandardTestDispatcher(scheduler))
A hard-coded Dispatchers.IO means a test cannot control when that work runs, so assertions race against a real thread pool and you end up with flaky tests and arbitrary delay calls to "wait for it". Injecting the dispatcher lets a test substitute one driven by virtual time, so advanceUntilIdle() deterministically finishes the work.
The one you cannot inject is Dispatchers.Main, since viewModelScope hard-codes it. That is what Dispatchers.setMain(testDispatcher) exists for, usually wrapped in a JUnit rule.
A common refinement in larger codebases is to inject a small provider instead of individual dispatchers:
interface DispatcherProvider {
val io: CoroutineDispatcher
val default: CoroutineDispatcher
val main: CoroutineDispatcher
}
That way one Hilt binding swaps every dispatcher in a test at once, rather than each class declaring its own default.
Step back and the whole topic is one decision repeated at different layers.
Underneath, Dispatchers.Main is a Handler on the main Looper, so a coroutine resuming on Main is ultimately a Message in the same queue that delivers touch events and draw calls. IO and Default are views onto one shared pool with different concurrency ceilings. Nothing here is magic: coroutines added a suspension mechanism, not a new threading model.
The answers worth having ready:
- **Which dispatcher?** Main for UI, Default for computation, IO for blocking calls. Match the pool to the work. - **How do I limit concurrency?** limitedParallelism(n), not a custom pool. - **How do I make a function main-safe?** withContext at the innermost blocking call, so callers never have to think about it. - **How do I avoid a lock?** Confine the state to limitedParallelism(1). - **How do I test it?** Inject the dispatcher; use setMain for the one you cannot.
The failure mode interviewers listen for is dispatcher cargo-culting: wrapping every repository function in withContext(Dispatchers.IO) because it feels safe. It signals that main-safety is being treated as a ritual rather than a property, and it is the single most common coroutine smell in real Android codebases.