Interview QuestionPractical QuestionFollow-up Questions

Coroutine Dispatchers: IO vs Main

skydovesJaewoong Eum (skydoves)||12 min read

Coroutine Dispatchers: IO vs Main

Kotlin coroutines decouple what a coroutine does from where it executes. The "where" is determined by the coroutine dispatcher, a component that assigns coroutine continuations to threads. Two dispatchers dominate Android development: Dispatchers.Main, which confines execution to the single UI thread, and Dispatchers.IO, which distributes work across a shared pool of background threads sized for blocking operations. Choosing the wrong dispatcher for a given task produces consequences ranging from imperceptible frame drops to system-level ANR dialogs. Understanding the threading model behind each dispatcher, the mechanics of switching between them, and the design rationale for their thread pool configurations is essential for writing concurrent code that is both correct and performant. By the end of this lesson, you will be able to:

  • Explain the threading model behind Dispatchers.Main and why it is restricted to a single thread.
  • Describe how Dispatchers.IO manages its thread pool and how it shares threads with Dispatchers.Default.
  • Trace the execution flow of a coroutine that switches between dispatchers using withContext.
  • Identify the consequences of running blocking I/O on the main thread, including jank and ANR triggers.
  • Apply limitedParallelism to create dispatcher views that constrain concurrency for specific resources.

Dispatchers.Main: The Single UI Thread

Dispatchers.Main confines coroutine execution to the application's main thread. On Android, this is the thread that the system creates when the process starts, the same thread that handles Activity lifecycle callbacks, input events, measure-layout-draw passes, and Choreographer frame callbacks. There is exactly one main thread per process, and the Android framework enforces this constraint by throwing CalledFromWrongThreadException when non-main threads attempt to modify view hierarchies.

The main dispatcher on Android is provided by the kotlinx-coroutines-android artifact, which registers a MainCoroutineDispatcher backed by the Android Looper and Handler. When a coroutine is dispatched to Dispatchers.Main, the dispatcher posts the continuation as a Runnable to the main thread's Handler:

// Simplified internal mechanism
internal class HandlerDispatcher(
    private val handler: Handler
) : MainCoroutineDispatcher() {
    override fun dispatch(context: CoroutineContext, block: Runnable) {
        handler.post(block)
    }
}

Android also provides Dispatchers.Main.immediate, used by viewModelScope and lifecycleScope. The difference: Dispatchers.Main always posts to the handler queue, executing on the next loop iteration even if already on the main thread. Main.immediate checks the current thread and executes inline if it is already main, eliminating a one-frame delay:

// viewModelScope uses Main.immediate by default
val viewModelScope = CoroutineScope(
    SupervisorJob() + Dispatchers.Main.immediate
)

The key constraint of the main dispatcher is that it is backed by a single thread. Any work dispatched to it runs sequentially. If a coroutine on Dispatchers.Main calls a function that blocks the thread, whether through Thread.sleep, a synchronous network call, or a long computation, every other piece of work queued on the main thread is delayed. Frame rendering, touch event processing, and animation callbacks all stall until the blocking call returns.

Dispatchers.IO: The Blocking-Optimized Thread Pool

Dispatchers.IO is designed for operations that block the calling thread while waiting for external resources: network responses, file system reads, database queries, and interprocess communication. These operations spend most of their time waiting rather than computing, which means the thread is occupied but idle. The IO dispatcher addresses this by maintaining a larger pool of threads than would be appropriate for CPU-bound work.

Internally, Dispatchers.IO shares the same underlying thread pool as Dispatchers.Default. Both dispatchers schedule work onto threads from kotlinx.coroutines' CoroutineScheduler. The difference is in their parallelism limits. Dispatchers.Default limits concurrent coroutines to the number of CPU cores (with a minimum of two), which prevents CPU-bound tasks from over-subscribing the processor. Dispatchers.IO allows up to 64 concurrent coroutines by default (or the number of CPU cores, whichever is larger), accommodating the reality that most of those coroutines are blocked waiting for I/O and not consuming CPU time:

This interview continues for subscribers

Subscribe to Dove Letter for full access to exclusive interviews about Android and Kotlin development.

Become a Sponsor