Dispatchers — key concepts, types of dispatchers and how they work

Author: IT Sectr Published: 2026-06-22 Reading time: 9 min

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 — predefined coroutine dispatchers that manage task distribution across threads
  • Dispatchers.Main — Android UI thread, for updating the interface and working with View
  • Dispatchers.IO — pool for network requests, file read/write and database operations (up to 64 threads)
  • Dispatchers.Default — pool for CPU-intensive tasks based on the number of processor cores
  • Dispatchers.Unconfined — inherits the current thread, no switching

What are Dispatchers?

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.

DispatcherThread PoolMax ThreadsUsage
Dispatchers.MainOne (UI)1UI updates, LiveData, View
Dispatchers.IOIO pool64 (limitedParallelism)Network, files, database
Dispatchers.DefaultCPU poolN coresSorting, parsing, computations
Dispatchers.UnconfinedCurrent threadN/AIntermediate operations, tests

Dispatchers.Main: UI Thread

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()).

kotlin
// 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: Network and Disk Operations

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.

Limiting Parallelism

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.

kotlin
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: CPU-Intensive Tasks

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.

kotlin
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: Current Thread

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.

kotlin
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.

How to Choose a Dispatcher

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.

  • Do not run I/O operations on the Main dispatcher — this blocks the UI
  • Do not run CPU-intensive tasks on the IO dispatcher — this inefficiently consumes IO pool threads
  • Use limitedParallelism to control concurrency during bulk I/O operations
  • Switch dispatcher using withContext, not by creating a new scope
  • Inherit the dispatcher from scope if the coroutine does not require a specific pool

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

How are Dispatchers.IO and Dispatchers.Default different?

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.

Can I create my own dispatcher in Kotlin Coroutines?

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.

What happens when Dispatchers.Main is called from a background thread?

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.

How to limit the number of parallel I/O operations?

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.

When to use Dispatchers.Unconfined?

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

  • Dispatchers — CoroutineContext components that determine coroutine execution threads
  • Dispatchers.Main — for Android UI operations, one main thread
  • Dispatchers.IO — for network and disk operations, pool of up to 64 threads
  • Dispatchers.Default — for CPU-intensive computations, pool based on number of cores
  • Dispatchers.Unconfined — no thread binding, for intermediate operations
  • withContext — the main mechanism for switching between dispatchers inside a coroutine
  • Choosing the wrong dispatcher leads to UI lags or inefficient resource usage

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.

Discuss the project

Read also