Dispatchers: The IO Pool Is Not Infinite
Dispatchers.IO is capped at 64 threads and every blocking call holds one hostage. Why the failure is a cliff rather than a slope, how to see it in a thread dump, and how limitedParallelism partitions the pool so one slow subsystem can't starve the rest.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — Dispatchers.IO is not unlimited. It's backed by a shared pool with a default cap of
64 threads (or the core count, whichever is larger). Every blocking call holds one of those threads
for its whole duration, so 64 concurrent blocking operations starve everything else that uses IO — Room,
DataStore, your file cache — and the app appears to hang with no crash and no log. The fix is not a bigger
pool; it's Dispatchers.IO.limitedParallelism(n) to give each blocking subsystem a bounded private
slice. Also: suspending is not blocking, so wrapping a suspend Retrofit call in withContext(IO) buys
nothing.
The myth
"
Dispatchers.IOis for slow work, and it grows as needed, so throw anything slow on it."
Half right. It does grow as needed. It stops growing at 64.
// kotlinx.coroutines default
// kotlinx.coroutines.io.parallelism = 64
// (or Runtime.availableProcessors(), whichever is larger)
Dispatchers.Default is separate and much smaller: core count, minimum 2. Both are views over the same
underlying worker pool, which is why an oversubscribed IO can make a CPU-bound coroutine wait to be
scheduled as well.
What the failure actually looks like
It never presents as "a coroutine problem". It presents as a support ticket:
When I upload my photos, the rest of the app stops working. Even Settings.
Screens that never touch the network get slow, because they touch Room, and Room's suspending queries dispatch onto IO. So does DataStore. So does your disk cache, your log writer, your OkHttp cache eviction. They're all queueing behind sixty-four uploads that each hold a thread while a socket drains.
Two properties make this hard to catch:
- It's a cliff, not a slope. At 63 concurrent blocking calls, latency is normal. At 65, the app looks frozen. There's no gentle degradation to notice in QA.
- It's invisible on a good device. Fast wifi means each upload holds its thread for 200ms and you never stack 64 of them. On a train, each holds for 30 seconds.
Blocking versus suspending — the distinction that decides everything
A suspending call releases its thread while it waits. A blocking call does not.
// Suspends. Thread is free while the server thinks.
suspend fun user(): User = api.getUser()
// Blocks. Thread is held for the whole read.
fun logo(): ByteArray = File(path).readBytes()
This is why the following is a no-op that appears in a great many codebases:
// Pointless — Retrofit's suspend call never
// blocks the calling thread in the first place.
withContext(Dispatchers.IO) { api.getUser() }
The threads are consumed by the unglamorous blocking things: File and InputStream APIs, JNI, image
decode, Thread.sleep, Bitmap work, SQLite through a blocking driver, and any legacy vendor SDK whose
"async" is a callback on its own thread that you then bridge with a latch.
So the real question about a piece of code is never "is it slow?" — it's "does it hold a thread?"
Diagnosing it in ten seconds
Take a thread dump (Android Studio's thread view, or adb shell kill -3 <pid>) and count workers:
DefaultDispatcher-worker-1 RUNNABLE socketRead0
DefaultDispatcher-worker-2 RUNNABLE socketRead0
...
DefaultDispatcher-worker-64 RUNNABLE socketRead0
Sixty-four workers, all parked in the same frame, is not an ambiguous signal. If you'd rather assert it than eyeball it, in a debug build:
val ioThreads = Thread.getAllStackTraces().keys
.count { it.name.startsWith("DefaultDispatcher") }
Log that alongside your existing performance events and you'll see the cliff coming before a user does.
The fix: partition, don't enlarge
The instinct is to raise kotlinx.coroutines.io.parallelism. Resist it. A bigger shared pool just moves the
cliff and costs more memory — each thread has a stack, and 256 threads on a low-end device is real RSS.
The correct tool is limitedParallelism, which hands out a view of the pool with its own concurrency
ceiling:
@Singleton
class Dispatchers @Inject constructor() {
// Bounded slices. A greedy subsystem
// can only starve itself.
val uploads = Dispatchers.IO.limitedParallelism(4)
val imageDisk = Dispatchers.IO.limitedParallelism(8)
val logging = Dispatchers.IO.limitedParallelism(1)
}
Now sixty-four queued uploads occupy four threads, not sixty-four. Room's queries run. Settings opens. The upload feature gets slower, which is exactly the outcome you want — the cost lands on the feature causing it, instead of being spread evenly across the app.
Two details worth knowing:
limitedParallelism(1)is a clean, allocation-free replacement for anewSingleThreadContextor aMutexaround a non-thread-safe resource. Use it for anything with a single writer.- On
Dispatchers.IOspecifically, a view may exceed the 64 cap — IO is documented as elastic for its limited views. That's a feature (your bounded slice is honoured even when IO is saturated), but it does mean "sum of slices ≤ 64" isn't a constraint you need to satisfy, and also isn't a guarantee you get.
Choosing a dispatcher by what the code does
A rule that scales better than picking per call site:
| The work | Dispatcher |
|---|---|
suspend network / Room / DataStore |
Nothing. The library already dispatches. |
| Blocking file, stream, JNI, bitmap | A named, bounded IO.limitedParallelism(n) slice |
| CPU-bound: parsing, sorting, crypto | Dispatchers.Default |
| Anything touching UI state | Dispatchers.Main.immediate |
And inject the thing rather than referencing Dispatchers.IO at the call site — not for testability alone
(though UnconfinedTestDispatcher is easier when it's injected), but because a dispatcher named uploads
that exists in exactly one place is a budget you can see. Dispatchers.IO scattered across 300 files is
an unowned global.
The one legitimate reason to raise the cap
If you genuinely have hundreds of concurrent, mostly-idle blocking connections — a long-poll fan-out, a device that talks to many peripherals — then 64 is the wrong number and raising it is correct. Do it deliberately, at startup, with a comment explaining the workload:
// 200 concurrent BLE peripherals, each blocking
// on a read. Measured: +38MB RSS. Reviewed 2026-10.
System.setProperty(
"kotlinx.coroutines.io.parallelism", "200"
)
If you can't write that comment honestly, partition instead.
The rule
Dispatchers.IO is a shared, bounded resource, and every unpartitioned shared resource eventually gets
taken by the greediest caller. Suspending work costs you nothing there; blocking work costs you a thread
until it finishes. Give each blocking subsystem a named slice with a number in it, and the day one of them
goes slow, only that feature goes slow.