Opening
If there is one concept in this entire course that will immediately change how you write concurrent code, it is this one. The distinction between CPU-bound work and IO-bound work. Once you understand this distinction, you will understand why Dispatchers.Default and Dispatchers.IO exist. You will understand why adding more threads sometimes helps and sometimes does not. You will understand why coroutines are efficient for network calls but not for heavy computation. This is the single most practical performance concept for an Android developer, and most developers have never had it explained clearly. So let us fix that right now.
What CPU-Bound Means
CPU-bound work is work where the CPU is the bottleneck. The processor is actively computing — crunching numbers, transforming data, executing instructions — and it cannot go any faster because it is already working as hard as it can. Examples on Android: parsing a large JSON response into objects. Sorting a list of ten thousand items. Applying an image filter or transformation. Running a complex regular expression. Encrypting or decrypting data. Compressing a file. In all of these cases, the CPU is busy. If you look at the profiler, CPU utilization is high — eighty, ninety, a hundred percent on the core running this work. The thread is never waiting. It is always executing.
What IO-Bound Means
IO-bound work is work where the CPU is waiting for something external. A network response. A disk read. A database query. A Bluetooth device. The CPU has nothing to do — it issued the request and now it is just sitting there, idle, waiting for the response to come back. Examples on Android: making a Retrofit API call. Reading a file from storage. Querying a Room database. Downloading an image. Writing to SharedPreferences. In all of these cases, if you look at the profiler, CPU utilization is low — maybe two, five, ten percent. The thread is blocked but the CPU is doing nothing useful. It is like a chef who has put something in the oven and is standing there staring at the oven door instead of prepping the next dish.
Why It Matters: The Wrong Solution
Here is why this distinction is so critical. The solution to CPU-bound problems is fundamentally different from the solution to IO-bound problems. If your work is CPU-bound, you need more CPU power. You can parallelize the work across multiple cores. You can optimize the algorithm to do fewer operations. But adding more threads than you have cores does not help — if you have eight cores and eight threads all at one hundred percent, adding a ninth thread just adds context-switching overhead. If your work is IO-bound, more CPU does not help at all. The CPU is already idle. What you need is concurrency — the ability to issue multiple IO requests simultaneously so they overlap. While one request waits for the network, another can wait for the disk, and a third can wait for a database response. You do not need more CPU power; you need to stop wasting the CPU power you have.
Dispatchers.Default vs Dispatchers.IO
Now you understand why Kotlin coroutines have two main dispatchers. Dispatchers.Default is backed by a thread pool sized to the number of CPU cores. On a phone with eight cores, this pool has eight threads. This is perfect for CPU-bound work. You have one thread per core, maximizing CPU utilization without the overhead of context switching between too many threads. Dispatchers.IO is backed by a thread pool that can grow to sixty-four threads or more. Why so many? Because IO-bound threads are mostly idle — they are just waiting. Having sixty-four threads waiting for different IO operations costs very little CPU. Each thread uses some memory for its stack, but the CPU can switch between them trivially because none of them are actually computing. If you use Dispatchers.Default for IO work, you block one of your precious few CPU-bound threads with idle waiting. If you use Dispatchers.IO for CPU work, you create more threads than cores, wasting memory and adding context-switching overhead. Using the wrong dispatcher does not crash your app, but it degrades performance in subtle ways.
How to Tell Which One You Have
When you are looking at a piece of code, how do you determine if it is CPU-bound or IO-bound? The simplest heuristic is to ask: does this code call out to something external? If the code makes a network call, reads a file, queries a database, or communicates with hardware — it is IO-bound. If the code transforms data in memory — parsing, sorting, filtering, computing, encrypting — it is CPU-bound. For confirmation, profile it. Run the code and watch CPU utilization. If the CPU is pegged at high utilization, it is CPU-bound. If the CPU is low and the wall-clock time is still long, it is IO-bound. Some operations are a mix. Image decoding reads bytes from disk (IO-bound) and then decompresses them (CPU-bound). In these cases, you want to separate the phases: read on Dispatchers.IO, decode on Dispatchers.Default.
Real Android Examples
Let me walk through common Android operations and classify them. Retrofit API call: IO-bound. The CPU sends an HTTP request and waits for the server to respond. Room database query: IO-bound. SQLite reads data from disk. JSON parsing with Moshi or Gson: CPU-bound. The parser is actively reading characters, building objects, allocating memory. Image decoding with BitmapFactory: CPU-bound. Decompressing JPEG or PNG is heavy computation. File download: IO-bound. Writing bytes to disk. RecyclerView DiffUtil calculation: CPU-bound. Comparing two lists element by element. Protobuf serialization: CPU-bound. Encoding structured data into a binary format. SharedPreferences commit: IO-bound. Writing to an XML file on disk. Knowing these classifications tells you which dispatcher to use for each operation.
The withContext Pattern
In practice, you often chain CPU-bound and IO-bound work together. You fetch data from the network, then parse it, then store it in the database. The idiomatic coroutine pattern is withContext. You start on Dispatchers.IO for the network call. When the response comes back, you switch to Dispatchers.Default for parsing. Then you switch back to Dispatchers.IO for the database write. Each step runs on the dispatcher that matches its nature. The beauty of coroutines is that this switching is almost free — it is just a continuation being dispatched to a different thread pool. No thread creation, no heavyweight context switch, just a task moving from one pool to another. This is far more efficient than creating a new thread for each step.
The Main Thread Special Case
There is a third case for Android: the main thread. The main thread handles UI rendering, touch events, and animation. It must never be blocked. When it runs code, it is CPU-bound — layout, measure, draw, recomposition are all computation. But it has a hard deadline: sixteen milliseconds per frame for sixty FPS. Anything that takes longer causes jank. This means you must keep both CPU-bound and IO-bound work off the main thread. Move network calls to Dispatchers.IO. Move heavy parsing to Dispatchers.Default. Let the main thread do only what it must: render the UI. The main dispatcher — Dispatchers.Main — is not a thread pool. It is the single main thread. When you launch a coroutine on Main, it runs on the same thread that handles touch events and drawing. Keep it light.
Common Mistakes
Let me highlight mistakes I see constantly. First: running Room queries on the main thread. Room warns you about this with an exception by default, but some developers disable the warning. Do not. Even a simple query can take ten to fifty milliseconds, which is a noticeable fraction of your frame budget. Second: doing JSON parsing on Dispatchers.IO. Parsing is CPU-bound. It works, but you are wasting IO thread pool slots with computation that should be on Default. Third: using Dispatchers.Default for everything. Default has only as many threads as CPU cores. If you block half of them waiting for network responses, your CPU-bound work has fewer cores available. Fourth: launching hundreds of coroutines on Dispatchers.Default for IO operations. The default pool is small by design. You will saturate it with idle waiting.
The Coroutine Advantage for IO
Here is why coroutines truly shine for IO-bound work. Traditional threads are expensive — each one needs about one megabyte of stack memory. If you want to have a thousand concurrent network calls, you need a thousand threads, which is one gigabyte of memory just for stacks. Coroutines are cheap — a few hundred bytes each. You can launch ten thousand coroutines that each wait for a network response, and they collectively use a tiny fraction of the memory that threads would. This is because coroutines suspend instead of blocking. When a coroutine hits a suspend point — like a network call — it releases its thread. The thread goes back to the pool to do other work. When the response arrives, the coroutine resumes on an available thread. No thread sits idle waiting. This is why people say "coroutines are lightweight threads." For IO-bound work, this is exactly right.
Closing
Let me summarize with the framework I want you to carry forward. When your code is CPU-bound — transforming data, computing, parsing — use Dispatchers.Default and optimize the algorithm if it is slow. When your code is IO-bound — network, disk, database — use Dispatchers.IO and optimize by issuing concurrent requests. Never block the main thread with either type of work. Use withContext to switch dispatchers as the nature of the work changes. And when in doubt, profile. Measure CPU utilization. The data will tell you which type of work you are dealing with. This single distinction — CPU-bound versus IO-bound — will improve every piece of concurrent code you write. Next, we are going to learn how to actually read profiling traces — not just capture them, but understand what they are telling you.