Kotlin Coroutines completely changed the way developers write asynchronous and concurrent code. At the heart of coroutine performance and behavior lies an often misunderstood concept: Dispatchers. Dispatchers decide where your coroutine will execute—on which thread or thread pool.
Understanding Dispatchers is essential for writing scalable Android apps, backend servers, and desktop applications. In this article, you’ll learn not just what each dispatcher does, but why it matters, when to use each one, and the patterns experienced developers apply to avoid bugs and performance pitfalls.
The sections we’ll cover:
- What is a dispatcher?
- How dispatchers map to threads
- Dispatchers.IO vs Dispatchers.Default — real performance differences
- The Android Main dispatcher
- Why Unconfined is dangerous
- Switching context with
withContext - Practical patterns for Android + API services
Let’s begin.
What Is a Dispatcher?
A dispatcher in Kotlin Coroutines determines which threads your coroutine will run on.
Dispatchers are part of the coroutine context:
launch(Dispatchers.IO) {
// runs on IO thread pool
}
A dispatcher decides:
- Should the coroutine run on a background thread?
- Should it run on the CPU-intensive thread pool?
- Should it run on the Android Main/UI thread?
- Should it stay on the current thread?
In simple terms:
A dispatcher is to coroutines what a thread pool is to threads.
Without dispatchers, all coroutines would run on the same thread, defeating the purpose of concurrency.
Why are dispatchers important?
- They optimize performance
- They prevent blocking the main thread
- They control where expensive operations run
- They help maintain thread safety
For example:
- Reading a large JSON file? → IO dispatcher
- Running a sorting algorithm? → Default dispatcher
- Updating the UI? → Main dispatcher
- Don’t care? → Wrong answer. You should care.
Next, let’s look at how dispatchers actually map to threads.
How Dispatchers Map to Threads
Coroutines aren’t threads—they run on threads. A dispatcher decides which thread or thread pool is used.
Let’s examine each dispatcher and what it controls.
1. Dispatchers.Default
Used for CPU-intensive work, such as:
- JSON parsing
- Encryption
- Compression
- Sorting
- Mathematical computations
Default uses a shared pool of threads equal to the number of CPU cores.
Example:
launch(Dispatchers.Default) {
val result = heavyCalculation()
println("Done: $result")
}
If you have 8 CPU cores, Default may run up to 8 intensive tasks at the same time.
2. Dispatchers.IO
Designed for blocking I/O operations, such as:
- Database access
- File reads/writes
- Network calls
- Logging
- Disk I/O
IO uses a much larger thread pool, often up to 64 threads or more.
Example:
launch(Dispatchers.IO) {
val data = database.query()
println(data)
}
Because reading files or making network requests often takes time but not much CPU, more IO threads can run concurrently without clogging CPU resources.
3. Dispatchers.Main
Available on Android and desktop UI frameworks (like Compose or Swing).
Runs on the UI thread, used for:
- Updating UI elements
- Showing dialogs
- Collecting LiveData/StateFlow
- Responding to user input
Example:
lifecycleScope.launch(Dispatchers.Main) {
textView.text = "Loading..."
val user = api.getUser() // suspend, runs on background thread internally
textView.text = user.name
}
Main is the only dispatcher allowed to modify UI components.
4. Dispatchers.Unconfined
Runs the coroutine in the current call stack, with no fixed thread.
This is very unpredictable.
Example:
launch(Dispatchers.Unconfined) {
println("Before delay: ${Thread.currentThread().name}")
delay(100)
println("After delay: ${Thread.currentThread().name}")
}
Output might show it started on one thread, then resumed on another.
This can cause race conditions and thread safety bugs.
Unconfined is almost never what you want.
We’ll discuss why in a later section.
Dispatchers.IO vs Dispatchers.Default: Real Performance Differences
Many beginners wonder:
“Can I just use IO for everything? It seems faster.”
This is a common mistake. Let’s break down the real difference.
1. Default = limited pool (CPU-constrained)
Default limits the number of threads to avoid overwhelming your CPU cores.
If you start 100 CPU-intensive tasks, Default won’t create 100 threads.
It keeps the system stable and predictable.
2. IO = large pool (I/O-constrained)
IO is optimized for operations that block threads:
- Network calls
- Reading from disk
- JDBC / SQL
- HTTP APIs
While one IO thread is waiting for a response, it isn’t using CPU time—so more IO threads are allowed.
3. Why not run everything on IO?
Because using IO for CPU work steals threads from I/O tasks.
Consider this:
You launch 500 CPU tasks using IO:
launch(Dispatchers.IO) { heavyCalculation() }
Now your database and network operations must wait, because the IO pool is clogged.
Files load slower.
Network calls get delayed.
Users wait longer.
Using the wrong dispatcher leads to thread starvation.
Summary Table
| Task Type | Correct Dispatcher |
|---|---|
| CPU-heavy work | Default |
| File/network/database operations | IO |
| UI updates | Main |
| Experiments / testing weird behavior | Unconfined (rare) |
Android Main Dispatcher
On Android, Dispatchers.Main maps to the UI thread.
This is the only thread that can update views.
Typical pattern:
lifecycleScope.launch(Dispatchers.Main) {
showLoading()
val user = withContext(Dispatchers.IO) { api.getUser() }
updateUI(user)
}
Why not run network calls on Main?
Because Android enforces strict thread rules:
- Network calls on Main →
NetworkOnMainThreadException - Heavy work on Main → ANR (freeze)
- Database work on Main → UI jank
Main must be kept clean and fast.
Main-Immedate vs Main
Dispatchers.Main.immediate executes immediately if already on Main.
Useful for Flow collectors.
Example:
flow.collectLatest { value ->
// will run immediately if already on Main
}
You don’t need to use this often as it’s an optimization detail.
Why Unconfined Is Dangerous
Dispatchers.Unconfined is unpredictable. It runs:
- On the current thread
- But after suspension, on whatever thread the suspending function resumes on
This breaks assumptions about thread safety.
Example:
launch(Dispatchers.Unconfined) {
println("Before: ${Thread.currentThread().name}")
delay(100)
println("After: ${Thread.currentThread().name}")
}
Output example:
Before: main
After: DefaultDispatcher-worker-3
This unpredictability makes Unconfined unsafe for:
- UI updates
- Shared mutable state
- Most real-world tasks
When is Unconfined useful?
Almost never.
One rare case: implementing a custom coroutine operator or internal library code.
For app development or backend work, avoid it entirely.
Switching Context with withContext
The withContext suspending function allows you to switch dispatchers inside a coroutine.
Example:
lifecycleScope.launch {
val user = withContext(Dispatchers.IO) {
api.getUser()
}
withContext(Dispatchers.Main) {
textView.text = user.name
}
}
How it works
- It doesn’t create a new coroutine
- It suspends the current coroutine
- It switches to another dispatcher
- It resumes back after completion
Why use withContext?
Because it follows structured concurrency.
This:
launch(Dispatchers.Main) {
val result = withContext(Dispatchers.IO) { compute() }
}
Is safer and cleaner than:
launch(Dispatchers.Main) {
val result = async(Dispatchers.IO) { compute() }.await()
}
async should be used for concurrency, not context switching.
Practical Patterns for Android + API Services
Let’s combine dispatchers into real-world patterns.
1. Network + UI update
lifecycleScope.launch(Dispatchers.Main) {
showLoading()
val user = withContext(Dispatchers.IO) {
api.getUser()
}
showUser(user)
}
This is the most common pattern in Android today.
2. Database + network combined
lifecycleScope.launch(Dispatchers.Main) {
val cached = withContext(Dispatchers.IO) { dao.getUser() }
if (cached != null) {
updateUI(cached)
}
val fresh = withContext(Dispatchers.IO) { api.getUser() }
updateUI(fresh)
withContext(Dispatchers.IO) { dao.save(fresh) }
}
UI always runs on Main.
All heavy work runs on IO.
3. Running multiple APIs in parallel
lifecycleScope.launch(Dispatchers.Main) {
val (user, posts) = withContext(Dispatchers.IO) {
val u = async { api.getUser() }
val p = async { api.getPosts() }
u.await() to p.await()
}
updateUI(user, posts)
}
Parallelism inside IO → UI update on Main.
4. CPU + IO mix
lifecycleScope.launch {
val rawData = withContext(Dispatchers.IO) {
file.read()
}
val parsed = withContext(Dispatchers.Default) {
parseJson(rawData)
}
updateUI(parsed)
}
Right dispatcher for each type of work.
Conclusion
Dispatchers are one of the most important parts of Kotlin Coroutines, yet they are often misunderstood. Choosing the right dispatcher can mean the difference between a smooth app and a janky, slow, or even crashing one.
You learned:
- Dispatchers define where coroutines execute
- Default = CPU work
- IO = blocking I/O work
- Main = UI thread
- Unconfined = dangerous and rarely appropriate
- Use
withContextto switch threads safely - Real-world Android patterns that combine UI + network + CPU
Key takeaways
- Don’t block Main. Ever.
- Use Default for CPU-heavy work.
- Use IO for file/network/database operations.
- Avoid Unconfined unless you know exactly why you need it.
- Use withContext for switching dispatchers, not async.
