Opening

You now know that coroutines are state machines that suspend and resume, borrowing threads from pools rather than owning them. But here is the question we have not answered yet: when a coroutine resumes, which thread does it run on? Who decides? The answer is the dispatcher. Dispatchers are the traffic controllers of the coroutine world. They decide which thread or thread pool a coroutine runs on, and choosing the right dispatcher is one of the most impactful decisions you make as an Android developer. Use the wrong one and your app will freeze, crash with an ANR, or waste resources. Today we are going to understand all four built-in dispatchers, how withContext switches between them, and how structured concurrency ties everything together into a system that is both powerful and safe.

Dispatchers.Main: The UI Thread

Let us start with the one you use most often: Dispatchers.Main. On Android, Dispatchers.Main dispatches coroutines onto the Android main thread. Remember from our threads video, the main thread runs a Looper that processes messages from a MessageQueue. When you launch a coroutine with Dispatchers.Main, the coroutine's code is posted as a message to that queue. When the main thread's Looper gets to it, it executes your code. This means Dispatchers.Main is single-threaded. Only one coroutine runs on it at a time, and each one runs until it suspends or completes before the next one starts. This is actually a feature, not a limitation. It gives you thread confinement for free. All your UI updates, state mutations, and LiveData or StateFlow observations can happen on Dispatchers.Main without any synchronization, because there is only one thread, so there are no race conditions. There is also Dispatchers.Main.immediate, which is a small but important optimization. If you are already on the main thread and you call withContext with Dispatchers.Main.immediate, it does not post to the queue. It executes immediately, avoiding the overhead of waiting for the next Looper cycle.

Dispatchers.Default: CPU-Bound Work

Dispatchers.Default is for CPU-intensive work: parsing JSON, sorting large lists, computing image transformations, heavy business logic. It is backed by a shared thread pool that is sized to the number of CPU cores on your device. If your phone has eight cores, the default pool has roughly eight threads. Why match the core count? Because CPU-bound work benefits from true parallelism. Each thread runs continuously on a core, doing actual computation. Having more threads than cores would just cause unnecessary context switching and cache thrashing, exactly what we talked about in the context switching video. Having fewer would leave cores idle. The core-count pool is the sweet spot. When you launch many coroutines on Dispatchers.Default, they get queued up and execute in batches. If you launch fifty coroutines, eight will run in parallel, and as each one suspends or completes, the next one starts. The thread pool is shared across your entire application, so you do not need to create or manage your own pool for most CPU-bound tasks.

Dispatchers.IO: Blocking Calls

Dispatchers.IO is designed for operations that block the thread: reading files, making network calls with blocking libraries, database queries, SharedPreferences access, anything that calls a blocking API. The IO dispatcher is backed by an elastic thread pool that can grow up to sixty-four threads, or more if you configure it. Why so many threads when Dispatchers.Default limits itself to core count? Because IO-bound work is different from CPU-bound work. When a thread is doing IO, it is mostly blocked, sitting there waiting for a disk read or network response to come back. It is not doing CPU computation, so it is not competing for cache space or ALU time. Having many threads for IO means you can have many concurrent IO operations waiting simultaneously without exhausting your thread pool. Here is an important detail: Dispatchers.IO and Dispatchers.Default actually share the same underlying thread pool. When you switch from Default to IO, the system does not necessarily move you to a different thread. It just changes the rules about how many threads can be active. This sharing is an optimization that avoids unnecessary thread creation and context switches when you use withContext to move between Default and IO.

Dispatchers.Unconfined: Run Wherever

Dispatchers.Unconfined is the wild card, and you should use it rarely. A coroutine on Dispatchers.Unconfined starts executing on whatever thread launched it. Then, after the first suspension point, it resumes on whatever thread the suspend function happened to call resumeWith on. So the coroutine can jump between threads unpredictably. This makes it unsuitable for any code that touches UI or has thread-safety requirements. So why does it exist? It is useful in a few specific cases. Testing, where you want coroutines to run eagerly without dispatching. Event handling, where you want zero dispatch overhead and you know your code is thread-safe. And some internal library code where the overhead of dispatching matters and the code is carefully written to be thread-agnostic. For application code on Android, you will almost never use Dispatchers.Unconfined. Stick to Main, Default, and IO.

withContext: Switching Dispatchers

Now let us talk about how you switch between dispatchers in practice. The answer is withContext. When you write withContext followed by Dispatchers.IO and a block of code, you are telling the coroutine to suspend, move to the IO dispatcher, execute the block, and then move back to whatever dispatcher you were on before. It is like a round trip. You leave, do your work somewhere else, and come back. Here is a typical pattern. You are on Dispatchers.Main, updating UI. You need to fetch data from a file. You call withContext Dispatchers.IO to read the file, then the coroutine returns to Main and you update the UI with the result. The code looks sequential, but under the hood the coroutine is moving between thread pools, suspending and resuming through the state machine we discussed in the last video. withContext is also smart about optimization. If you call withContext with the dispatcher you are already on, it does not actually switch. It just runs the block immediately. And as I mentioned earlier, Default and IO share threads, so switching between them often avoids a real thread switch.

Why Wrong Dispatcher Equals ANR

Let us talk about what goes wrong when you choose poorly. The most common mistake is doing blocking or CPU-intensive work on Dispatchers.Main. If you parse a large JSON response on the main thread, that might take fifty or a hundred milliseconds. During that time, the main thread cannot process UI messages. It cannot respond to touch events, cannot draw frames, cannot update animations. If you stack up enough of these delays, or if one operation takes more than five seconds, the system shows the Application Not Responding dialog. That is an ANR, and it is one of the most impactful metrics in the Google Play Console. Here is the other side of the mistake: using Dispatchers.IO for CPU-bound work. If you launch heavy computations on IO, you might spin up dozens of threads all doing CPU work, causing massive context switching and cache thrashing. Your computation takes longer, uses more battery, and might even slow down IO operations that should be completing quickly. Match the dispatcher to the work type. Main for UI. Default for CPU. IO for blocking calls. It is that simple.

Practical Dispatcher Patterns

Let me give you the patterns I use in every Android project. For ViewModels, I launch coroutines on Dispatchers.Main because I need to update UI state. When I need to call a repository, I use withContext Dispatchers.IO inside the repository function, so the ViewModel does not need to know about threading. The repository is responsible for switching to the right dispatcher. For CPU-heavy mapping or transformation of data, I use withContext Dispatchers.Default. For Room database calls, even though Room has built-in suspend support that handles its own threading, wrapping in withContext Dispatchers.IO is still good practice for clarity and consistency. For Retrofit network calls, the suspend support handles threading internally, but I still ensure the surrounding code respects dispatcher boundaries. The general principle is: push the dispatcher switch as close to the actual blocking or CPU-intensive call as possible. Keep your callers on Main and let each function internally switch to the appropriate dispatcher for its work.

CoroutineScope and Structured Concurrency

Dispatchers are half the story. The other half is how you manage the lifecycle of your coroutines. This is where CoroutineScope and structured concurrency come in. A CoroutineScope defines a boundary for coroutines. Every coroutine must be launched in a scope, and when that scope is cancelled, all coroutines inside it are cancelled too. In Android, viewModelScope is cancelled when the ViewModel is cleared. lifecycleScope is cancelled when the lifecycle is destroyed. This prevents memory leaks and runaway work. Structured concurrency also means that a parent coroutine waits for all its children to complete. If you launch ten coroutines inside a coroutineScope block, the block does not return until all ten finish. If one of them throws an exception, the others are cancelled. This is a fundamentally different model from raw threads, where you create a thread and it runs independently with no parent-child relationship, no automatic cancellation, and no automatic error propagation.

How Cancellation Works with Dispatchers

Cancellation ties directly into dispatchers. When a scope is cancelled, every coroutine in it is marked as cancelled. At each suspension point, the coroutine checks its cancellation status. If it has been cancelled, it throws a CancellationException, which unwinds the coroutine's state machine cleanly. This is why suspension points matter so much. A coroutine that never suspends can never be cancelled. If you have a tight CPU loop that does not call any suspend functions, it will ignore cancellation entirely. The fix is to periodically call ensureActive or yield inside your loops, giving the coroutine a chance to check its cancellation status. Also, when you switch dispatchers with withContext, the cancellation check happens at the switch point. So a cancelled coroutine will not even start executing on the new dispatcher. This prevents wasted work and keeps your app's resource usage predictable when the user navigates away from a screen.

Putting It All Together

Let me show you a complete picture of how all of this works together in a real Android scenario. A user taps a button. The click handler runs on the main thread. It calls a ViewModel function, which launches a coroutine in viewModelScope on Dispatchers.Main. The coroutine calls a repository function that uses withContext Dispatchers.IO to make a network request. The coroutine suspends, and the main thread is freed to process UI events. The network response arrives. The IO thread resumes the coroutine inside withContext. The repository parses the response, perhaps switching to Dispatchers.Default for CPU-heavy JSON processing. The coroutine returns from withContext back to Dispatchers.Main with the parsed data. The ViewModel updates a StateFlow, and the UI recomposes. If the user navigates away during the network call, viewModelScope is cancelled, which cancels the coroutine at its next suspension point, cleaning up everything automatically. Every piece we covered in this phase, threads, context switching, race conditions, memory visibility, coroutine internals, and dispatchers, is present in this simple flow. That is the whole picture.

Recap and Phase Wrap-Up

Let us recap the dispatchers. Main runs on the Android UI thread Looper, single-threaded, perfect for UI updates. Default is a thread pool sized to CPU cores, for computation. IO is an elastic pool for blocking calls, can grow to sixty-four plus threads. Unconfined starts on the caller's thread, resumes wherever, rarely needed in app code. withContext switches dispatchers and returns to the original one when done. CoroutineScope and structured concurrency manage lifecycle, cancellation, and error propagation. And that wraps up Phase 2, our deep dive into concurrency. You now understand threads at the OS level, the cost of context switching, why race conditions and visibility bugs happen, how the coroutine compiler transform works, and how dispatchers decide where your code runs. These are not just theoretical concepts. They are the foundation of every line of concurrent code you write. In the next phase, we are going to look at data and memory: how your objects are laid out in memory, how the garbage collector works, and why data structures have the performance characteristics they do. See you there.