If you've started using Kotlin Coroutines, you've probably seen code like Dispatchers.IO or Dispatchers.Main and wondered — what does this actually do, and why does it matter? This guide explains dispatchers in plain language with real examples and analogies so it all makes sense.
The Problem Dispatchers Solve
In Android, there is one rule you must never break:
Never do heavy work on the main thread.
The main thread is responsible for drawing your UI. If you block it with a network call or database query, the screen freezes. The user sees an ANR (App Not Responding) dialog. Bad experience.
So heavy work needs to run on a background thread. But after the work is done, you need to come back to the main thread to update the UI.
Dispatchers are what control which thread your coroutine runs on.
Real-world analogy: Think of your app as a restaurant. The main thread is the front-of-house manager who talks to customers (the UI). The background threads are the kitchen staff doing the actual cooking (data work). A dispatcher is the intercom system that routes tasks — "this goes to the kitchen" or "this goes back to the manager."
What Is a Dispatcher?
A dispatcher is a part of the coroutine context that decides which thread or thread pool a coroutine runs on.
When you launch a coroutine, you pass a dispatcher to tell it where to run:
// Without dispatcher — uses default
viewModelScope.launch {
// runs on Main by default in ViewModel
}
// With dispatcher — explicit control
viewModelScope.launch(Dispatchers.IO) {
// runs on background IO thread
}
Kotlin gives you four built-in dispatchers. Let's go through each one.
1. Dispatchers.Main
Runs the coroutine on the main UI thread.
Analogy: This is the front-of-house manager. Everything that involves talking to the customer (updating UI, showing a dialog, changing text) must go through them.
Use it for:
- Updating
TextView,RecyclerView, any UI component - Showing/hiding views
- Navigation actions
// In a ViewModel viewModelScope.launch(Dispatchers.Main) { // Safe to update UI here textView.text = "Hello!" progressBar.visibility = View.GONE }In practice, you rarely need to explicitly write
Dispatchers.Mainin a ViewModel becauseviewModelScopealready runs on Main by default. But you will use it when switching back from a background thread:viewModelScope.launch(Dispatchers.IO) { val data = repository.fetchUser() // background thread withContext(Dispatchers.Main) { nameTextView.text = data.name // back to UI thread } }⚠️ Never do this on Main:
viewModelScope.launch(Dispatchers.Main) { val data = api.fetchData() // ❌ network on main thread — CRASH }
2. Dispatchers.IO
Runs the coroutine on a background thread pool optimized for waiting.
Analogy: This is the kitchen staff that spends a lot of time waiting — waiting for an oven to preheat, waiting for a delivery to arrive. They are not doing heavy lifting, just waiting on something external. IO threads are like this — they wait on the network, on a file read, on a database response.
Use it for:
- Network calls (Retrofit, OkHttp)
- Reading/writing files
- Room database queries
- SharedPreferences / DataStore reads
viewModelScope.launch(Dispatchers.IO) { // All of these are safe here val user = userDao.getUser(userId) // Room DB val posts = apiService.getPosts() // Retrofit network call val file = File(path).readText() // File read }Real Android example — fetch data and show in UI:
viewModelScope.launch(Dispatchers.IO) { try { val articles = newsApi.getLatestArticles() // network call on IO thread withContext(Dispatchers.Main) { adapter.submitList(articles) // update UI on Main thread progressBar.visibility = View.GONE } } catch (e: Exception) { withContext(Dispatchers.Main) { Toast.makeText(context, "Failed to load", Toast.LENGTH_SHORT).show() } } }Under the hood:
Dispatchers.IOuses a shared thread pool that can grow up to 64 threads (or the number of CPU cores, whichever is higher). It is designed for tasks that block while waiting, not for tasks that crunch numbers.
3. Dispatchers.Default
Runs the coroutine on a background thread pool optimized for CPU work.
Analogy: These are the specialist chefs doing the actual cooking — chopping, mixing, calculating. They are actively working the whole time, not waiting. CPU threads are like this — they are doing real computation continuously.
Use it for:
- Sorting or filtering large lists
- Parsing large JSON responses
- Image processing or bitmap manipulation
- Mathematical calculations
- Any CPU-heavy work
viewModelScope.launch(Dispatchers.Default) { // Sort a list of 100,000 items — heavy CPU work val sorted = bigList.sortedByDescending { it.score } withContext(Dispatchers.Main) { adapter.submitList(sorted) } }Another example — parsing JSON manually:
viewModelScope.launch(Dispatchers.Default) { val jsonString = readRawFile() // get raw string val parsedData = parseComplexJson(jsonString) // CPU-heavy parsing withContext(Dispatchers.Main) { updateUI(parsedData) } }Under the hood:
Dispatchers.Defaultuses a thread pool with size equal to the number of CPU cores on the device (minimum 2). This prevents too many threads from competing for CPU time.
4. Dispatchers.Unconfined
Starts in the current thread but after the first suspension point, resumes in whatever thread handled the suspension.
Analogy: A freelance worker with no fixed desk. They start work at whatever desk is available, and after a break, they sit wherever is free when they come back.
Use it for:
- Testing
- Very specific edge cases where you need the coroutine to resume wherever it left off
- Not recommended for general use
launch(Dispatchers.Unconfined) { println("Before delay: ${Thread.currentThread().name}") // Prints: main delay(100) println("After delay: ${Thread.currentThread().name}") // Prints: kotlinx.coroutines.DefaultExecutor ← different thread! }The thread changed after
delay(). This makes behavior unpredictable in real apps. Avoid it unless you know exactly what you're doing.
Switching Between Dispatchers with withContext
The most common real-world pattern is: start on IO, come back to Main. You do this with withContext.
// Pattern: IO → Main
viewModelScope.launch(Dispatchers.IO) {
val result = database.loadData() // on IO thread
withContext(Dispatchers.Main) {
textView.text = result // on Main thread
}
}
// Pattern: Main → IO → Default → Main
viewModelScope.launch { // Main (default for viewModelScope)
showLoading() // Main — update UI
val rawData = withContext(Dispatchers.IO) {
api.fetchData() // IO — network call
}
val processed = withContext(Dispatchers.Default) {
heavyProcessing(rawData) // Default — CPU work
}
showResult(processed) // Main — update UI
}
This is clean, readable, and exactly how professional Android apps are structured.
Custom Dispatchers
Sometimes you need a dispatcher for a specific purpose — for example, a dedicated single thread for database writes to prevent race conditions, or a limited pool so background tasks don't eat up all resources.
Single thread dispatcher (useful for sequential operations):
// All tasks run one at a time, in order — no race conditions
val singleThreadDispatcher = Executors.newSingleThreadExecutor()
.asCoroutineDispatcher()
viewModelScope.launch(singleThreadDispatcher) {
database.write(record1) // finishes first
database.write(record2) // then this runs
database.write(record3) // then this
}
Limited thread pool (useful when you want to control concurrency):
// Max 3 tasks run at the same time
val limitedDispatcher = Executors.newFixedThreadPool(3)
.asCoroutineDispatcher()
repeat(10) { i ->
launch(limitedDispatcher) {
println("Task $i on thread: ${Thread.currentThread().name}")
delay(1000)
}
}
// Only 3 run simultaneously, others wait
⚠️ Important: Always close custom dispatchers when done to avoid thread leaks:
limitedDispatcher.close()
Quick Reference
| Dispatcher | Thread | Use For | Avoid |
|---|---|---|---|
Dispatchers.Main |
UI thread | UI updates, navigation | Any blocking work |
Dispatchers.IO |
Background (up to 64) | Network, DB, files | CPU-heavy processing |
Dispatchers.Default |
Background (= CPU cores) | Sorting, parsing, math | Network or file I/O |
Dispatchers.Unconfined |
Current → varies | Testing only | Production code |
Common Mistakes
Mistake 1: Using IO for CPU work
// ❌ Wrong — IO threads are not meant for CPU-heavy work
launch(Dispatchers.IO) {
val sorted = millionItems.sortedBy { it.name } // should use Default
}
// ✅ Correct
launch(Dispatchers.Default) {
val sorted = millionItems.sortedBy { it.name }
}
Mistake 2: Updating UI from IO thread
// ❌ Wrong — crashes with CalledFromWrongThreadException
launch(Dispatchers.IO) {
val data = api.fetch()
textView.text = data // UI update on background thread!
}
// ✅ Correct
launch(Dispatchers.IO) {
val data = api.fetch()
withContext(Dispatchers.Main) {
textView.text = data
}
}
Mistake 3: Not specifying a dispatcher for network calls
// ❌ Wrong — in a regular coroutine scope, this may run on Main
launch {
val data = api.fetchData() // network on main thread — NetworkOnMainThreadException
}
// ✅ Correct
launch(Dispatchers.IO) {
val data = api.fetchData()
}
Summary
- Dispatchers control which thread your coroutine runs on
- Dispatchers.Main → UI thread only, for updating views
- Dispatchers.IO → background, for network, database, and file work
- Dispatchers.Default → background, for heavy CPU computation
- Dispatchers.Unconfined → avoid in production, use in tests only
- Use
withContextto switch dispatchers within a single coroutine - Create custom dispatchers when you need fine-grained thread control
Choosing the right dispatcher is one of the most important habits to build as an Android developer. Get it right, and your app stays smooth, responsive, and crash-free.
Happy coding!
Comments (0)