Dispatchers in Kotlin Coroutines are CoroutineContext components that determine the threads for executing coroutines: Main (UI thread), IO (network and disk), Default (CPU-intensive tasks) and Unconfined (current thread). Each dispatcher manages a specialized thread pool optimized for a specific type of work. According to JetBrains guide, 2024, choosing the right dispatcher is critical for application performance and stability.
Key Takeaways
Dispatchers are implementations of the CoroutineDispatcher interface, which are elements of CoroutineContext. They determine on which thread or thread pool the coroutine will execute. When creating a coroutine via launch or async, the dispatcher can be passed as the first parameter: launch(Dispatchers.IO) { ... }. If no dispatcher is specified, it inherits from the outer CoroutineScope.
Kotlin provides four built-in dispatchers: Main, IO, Default, Unconfined. Each dispatcher uses its own thread pool optimized for a specific type of operation. Choosing the right dispatcher determines application performance: a wrong choice leads to UI lags, idle CPU cores, or inefficient thread usage.
| Dispatcher | Thread Pool | Max Threads | Usage |
|---|---|---|---|
| Dispatchers.Main | One (UI) | 1 | UI updates, LiveData, View |
| Dispatchers.IO | IO pool | 64 (limitedParallelism) | Network, files, database |
| Dispatchers.Default | CPU pool | N cores | Sorting, parsing, computations |
| Dispatchers.Unconfined | Current thread | N/A | Intermediate operations, tests |
Dispatchers.Main is the dispatcher that executes coroutines on the Android main thread. It is designed for UI-related operations: updating TextView, calling notifyDataSetChanged, working with LiveData and StateFlow. In Android, this dispatcher is implemented via Handler (Looper.getMainLooper()).
// Correct switch to Main for UI updates
viewModelScope.launch(Dispatchers.IO) {
val data = repository.fetchData()
withContext(Dispatchers.Main) {
_uiState.value = data
}
}
If a coroutine is already on the Main dispatcher, an additional withContext(Dispatchers.Main) does not create overhead — the dispatcher checks the current thread and skips the switch. withContext is the preferred way to switch between dispatchers.
Dispatchers.IO is a dispatcher optimized for I/O operations: HTTP requests (Ktor, OkHttp), reading and writing files, working with Room or SQLDelight. It uses a pool of 64 threads by default, scalable under load. Each new I/O request can create an additional thread until the limit is reached.
To control the number of concurrent I/O operations, use limitedParallelism(). This function creates a new dispatcher with a limit on the number of parallel threads, preventing pool exhaustion during bulk operations.
val limitedIo = Dispatchers.IO.limitedParallelism(4)
// Load 100 files with limit of 4 concurrent operations
coroutineScope {
val files = (1..100).map { index ->
async(limitedIo) {
downloadFile("file_$index")
}
}
files.awaitAll()
}
Use the IO dispatcher for all operations where the coroutine spends time waiting (I/O-bound). CPU-intensive tasks on the IO dispatcher are inefficient — they occupy threads intended for I/O, reducing the system's throughput.
Dispatchers.Default is the dispatcher for computation operations that load the processor: sorting, filtering, JSON parsing (Moshi, Kotlinx Serialization), image processing, calculations. The pool size equals the number of processor cores (but not less than 2). This ensures maximum CPU utilization without context switching.
suspend fun processData(input: List<RawRecord>): List<ProcessedRecord> {
return withContext(Dispatchers.Default) {
input
.parallelStream()
.map { transform(it) }
.toList()
}
}
Do not use Dispatchers.Default for I/O operations — this will block CPU pool threads that could be processing computational tasks. Separating IO and Default allows optimal utilization of system resources: IO threads wait for I/O, CPU threads are constantly busy with computations.
Dispatchers.Unconfined is a special dispatcher that does not bind a coroutine to any pool. The coroutine starts execution in the thread where launch/async was called, and after suspension resumes in the thread that called resume. This behavior is suitable for intermediate operations that do not require a fixed context.
fun main() = runBlocking {
launch(Dispatchers.Unconfined) {
println("Before delay: ${Thread.currentThread().getName()}")
delay(500L)
println("After delay: ${Thread.currentThread().getName()}")
}
}
In production code, Dispatchers.Unconfined is rarely used. Main use cases: lightweight transformations before passing data to another dispatcher and tests. For production workloads, use explicit dispatchers — Unconfined is unpredictable because the execution thread depends on the resume implementation.
Dispatcher selection depends on the task type: UI operations → Main, I/O-bound → IO, CPU-bound → Default, intermediate → inherit from scope. For Android, it is recommended to launch a coroutine on the dispatcher where the main work is performed, and switch to Main via withContext before updating the UI.
For complex scenarios, combine dispatchers using the + operator: Dispatchers.IO + SupervisorJob() + CoroutineExceptionHandler. This creates a CoroutineContext with a specified dispatcher, error handling, and an isolated Job hierarchy.
Frequently Asked Questions
Dispatchers.IO uses a pool of up to 64 threads for I/O-bound operations (waiting for I/O), while Dispatchers.Default uses a pool based on the number of CPU cores for computational tasks. When threads are scarce, both pools can share threads with each other.
Yes, use newSingleThreadContext() for a single-threaded or newFixedThreadPoolContext() for a fixed pool. For production, use limitedParallelism() based on existing dispatchers — this is more efficient than creating new pools.
If Dispatchers.Main is unavailable (e.g., in a JUnit test or background service), an IllegalStateException is thrown. Use TestCoroutineDispatcher for tests, and Dispatchers.IO or Default for background services.
Use Dispatchers.IO.limitedParallelism(N), where N is the maximum number of parallel threads. This prevents pool exhaustion during mass requests and provides controlled parallelism.
Dispatchers.Unconfined is suitable for intermediate operations: lightweight data transformations before passing to another dispatcher, test scenarios. In production Android code, it is not recommended due to the undefined execution thread after suspension.
Summary
We will develop a mobile application turnkey
IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.
Read also