skip to content

Why is Dispatchers.IO the standard target for withContext when calling blocking APIs, and when would Dispatchers.Default be wrong or right?

level: middleimportance: should knowfreq 55%

answer

  1. IO = big pool (default 64) for blocking waits
  2. Default = core-sized pool for CPU work
  3. Blocking on Default starves compute threads
  4. IO and Default share an underlying pool
  5. limitedParallelism(n) caps against scarce resources

basics

~20 s

Dispatchers.IO has a large pool meant for tasks that wait on the network or disk, so many can block at once. Dispatchers.Default has only as many threads as CPU cores, good for heavy calculations but easily clogged by blocking calls.

solid answer

~40 s

Dispatchers.IO is sized for blocking I/O: it allows a large number of threads (default cap 64, or cores if more) so that many coroutines can sit blocked on JDBC, file, or blocking-socket calls without starving each other. Dispatchers.Default is capped at the number of CPU cores and is for CPU-bound work (parsing, hashing, image processing); putting a blocking call there can occupy a core thread and stall unrelated CPU work. So: blocking I/O → withContext(Dispatchers.IO); pure computation → withContext(Dispatchers.Default). IO and Default share an underlying thread pool, and IO.limitedParallelism(n) lets you carve out a bounded view to cap concurrency against, say, a small connection pool. Switching between IO and Default with no blocking and no compute (just to hop) is wasteful.

code

kotlin · 5 lines
kotlin
private val dbDispatcher = Dispatchers.IO.limitedParallelism(10)

suspend fun loadOrders(): List<Order> = withContext(dbDispatcher) {
    jdbcTemplate.query("SELECT * FROM orders", rowMapper) // blocking, capped at 10
}

go deeper

for a junior

Knows blocking I/O goes on Dispatchers.IO and not the main thread.

for a middle

Explains the pool-sizing rationale for IO vs Default and which work goes where.

for a senior

Uses limitedParallelism to bound concurrency and knows IO/Default share a pool.

for a principal

Designs dispatcher strategy across a service to protect CPU work and scarce I/O resources under load.

## The two non-UI dispatchers - **`Dispatchers.IO`** — designed for **blocking I/O**. Its concurrency cap defaults to **64** threads (or the number of cores if greater). The idea: a blocked thread costs little CPU, so you can afford many of them waiting on the network/disk. - **`Dispatchers.Default`** — designed for **CPU-bound** work. It is capped at **the number of CPU cores**, because running more compute threads than cores yields no speedup and adds context-switch overhead. ## Why blocking belongs on IO If you do a blocking JDBC call on `Dispatchers.Default`, that thread is **parked but counted** against the small core-sized pool. A handful of concurrent blocking calls can occupy every Default thread, starving genuine CPU work elsewhere in the app. `Dispatchers.IO` exists precisely to absorb that blocking with a much larger thread budget. ```kotlin suspend fun query() = withContext(Dispatchers.IO) { jdbc.query(...) } // right suspend fun hash(b: ByteArray) = withContext(Dispatchers.Default) { sha256(b) } // right ``` ## Shared pool and `limitedParallelism` `IO` and `Default` are **views over a shared thread pool**; switching between them does not always create new OS threads. To bound concurrency against a scarce resource (e.g., a DB connection pool of size 10), use: ```kotlin val dbDispatcher = Dispatchers.IO.limitedParallelism(10) withContext(dbDispatcher) { jdbc.query(...) } ``` This caps in-flight coroutines so you do not overwhelm the pool. ## When Default is the right call Pure, non-suspending **CPU work**: JSON-to-object mapping of large payloads, cryptography, compression, image transforms. These saturate cores, so the core-sized cap is exactly what you want. ## Common mistakes - Using `IO` for heavy computation (oversubscribes cores). - Using `Default` for blocking calls (starves the compute pool). - Adding `withContext` hops with neither blocking nor compute inside — pure overhead. ## Recap - Blocking I/O → `Dispatchers.IO` (big pool). - CPU-bound → `Dispatchers.Default` (core-sized pool). - Bound a scarce resource → `IO.limitedParallelism(n)`.

  • Why not just give Dispatchers.Default thousands of threads so it can absorb blocking too?
    More compute threads than cores adds context-switching overhead with no throughput gain; the small cap protects CPU-bound work. Blocking belongs on the separately-sized IO pool instead.
  • How do you stop a flood of withContext(Dispatchers.IO) calls from exhausting a 10-connection DB pool?
    Use Dispatchers.IO.limitedParallelism(10) as a dedicated dispatcher so at most 10 coroutines run that block concurrently.

saying these in an interview costs you the question

  • Saying IO and Default are interchangeable
  • Putting CPU-heavy work on Dispatchers.IO
  • Putting blocking JDBC on Dispatchers.Default
  • Not knowing IO's default 64 cap or limitedParallelism
  • Thinking each withContext(IO) always spawns a fresh OS thread

context