Dispatchers.IO is not in commonMain, and the reason matters
A browser has no thread pool to offload to.
TL;DR
Dispatchers.IOdoes not resolve incommonMain. The workaround that shipped — anexpect valreturning it per platform — gave wasmDispatchers.Default, which on wasm is the single UI thread, so a 40 ms JSON parse froze the page on every list load.Dispatchers.IOis a JVM-family concept: a large elastic pool for blocking calls. Kotlin/Native has a different model and wasm has one thread. There is nothing portable to alias it to.- The fix is to stop asking for a dispatcher by name and take one as a constructor parameter — which is the same change that makes the code testable, and is why the absence is a good API decision rather than a gap.
- What reading on buys: what each target actually has, the two measurements that separate a
blocking call from a CPU-bound one, and why
withContext(Dispatchers.Default)on wasm is a no-op worth knowing about.
The case this post is about
A repository parsing a large response, written on Android first:
// androidMain, originally
suspend fun loadFeed(): List<Post> = withContext(Dispatchers.IO) {
val raw = api.getFeed() // a blocking HTTP read, originally
json.decodeFromString<List<Post>>(raw) // ~40 ms for a 400-item feed
}
Moved to commonMain, which does not compile:
e: Unresolved reference: IO
commonMain/kotlin/feed/FeedRepository.kt:18
And the workaround, which does:
// commonMain
expect val ioDispatcher: CoroutineDispatcher
suspend fun loadFeed(): List<Post> = withContext(ioDispatcher) { … }
// androidMain / jvmMain
actual val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
// iosMain
actual val ioDispatcher: CoroutineDispatcher = Dispatchers.Default
// wasmJsMain
actual val ioDispatcher: CoroutineDispatcher = Dispatchers.Default
Five targets, one name, compiles everywhere.
The symptom
A frozen page on the web target, every time the feed loaded:
# Chrome DevTools ▸ Performance, loading a 400-item feed
Task duration
decodeFromString 38 ms
─ main thread blocked 38 ms ← the whole parse
Long Task warnings 1
INP (Interaction to Next Paint) 312 ms
Thirty-eight milliseconds is not much, and it is on the only thread there is — so a tap during the load registered 312 ms later. On a slower machine the same parse measured 140 ms and the page visibly stuttered.
What made it hard to see was that withContext(Dispatchers.Default) looks like offloading. The
code reads as "do this elsewhere", the coroutine genuinely suspends and resumes, and on wasm
"elsewhere" is the same thread it started on.

Why the obvious fix failed
The first attempt was Dispatchers.Unconfined, on the theory that the problem was the dispatch
itself:
actual val ioDispatcher: CoroutineDispatcher = Dispatchers.Unconfined
Worse, and instructively so: Unconfined runs the block immediately in the calling frame, so the
parse now happened without even a suspension point and the Long Task got marginally longer. It
removed the only thing the previous version was doing.
The second attempt was a limitedParallelism pool, which is the right tool on other targets:
actual val ioDispatcher: CoroutineDispatcher =
Dispatchers.Default.limitedParallelism(4)
On wasm, Dispatchers.Default has a parallelism of one, and limiting one to four is one. No
change, and a line of code that reads as though it did something.
The third attempt was a web worker, which is the genuine answer for CPU-bound work on the web and
is a much larger piece of work — the parse has to be serialisable across a postMessage
boundary, so json.decodeFromString cannot simply be moved; the string goes over and the
objects come back, and Kotlin/Wasm's worker support was not something we were going to adopt for
one parse.
What actually resolved it was noticing the question had been wrong. Dispatchers.IO is for
blocking calls, and by the time this code reached commonMain the HTTP call was Ktor's
suspending client, which does not block. The only thing left inside withContext was a CPU-bound
parse — a different problem with a different answer.
The actual mechanism
Dispatchers.IO names a resource that exists on one platform family.
On the JVM it is a large elastic thread pool, sized for blocking. Default 64 threads, growing on demand, intended for calls that occupy a thread while doing nothing — a blocking socket read, a file write, a JDBC query. Its size is the point: 64 threads is wasteful for computation and correct for waiting.
Kotlin/Native has no equivalent and does not need one in the same way. There is a Default
pool sized to the cores, and blocking calls are rarer because the platform APIs are
callback-or-async. An "IO" alias would be a pool with the wrong size for either use.
wasm has one thread. Not a small pool — one. Dispatchers.Default is a dispatcher onto the
same event loop, so dispatching to it changes when the block runs and never where. There is
nothing for a pool to be.
So expect val ioDispatcher asks each platform to name its blocking pool, and three of five have
none. The honest answers would be "there is no such thing here", which a CoroutineDispatcher
return type cannot express — so the actuals returned the nearest plausible thing and the nearest
plausible thing was a lie on the target where it mattered.
And the deeper point is that the name was doing two jobs. withContext(Dispatchers.IO) in the
original Android code meant get off the main thread, and it was achieving that for two different
reasons at once — a blocking HTTP read and a CPU-bound parse. Those want different dispatchers on
every platform, and the alias collapsed them. Once the HTTP call became suspending, only the
CPU-bound half remained, and the correct dispatcher for CPU-bound work is Default on four targets
and a different architecture on the fifth.

The fix
Take the dispatcher as a parameter, name it for the work rather than for the platform resource, and let the one target that cannot offload say so.
// commonMain/kotlin/concurrency/Dispatchers.kt
//
// Named for what the work IS, not for a platform pool. Two properties because the two kinds of
// work want different things on every target, and the original single alias was hiding that.
interface AppDispatchers {
/** CPU-bound work: parsing, sorting, image decoding. Wants core-count parallelism. */
val compute: CoroutineDispatcher
/** Calls that OCCUPY a thread while waiting. Empty on targets with no blocking APIs. */
val blocking: CoroutineDispatcher
/**
* Whether `compute` can actually run off the UI thread. The web cannot, and a caller doing
* something expensive needs to know — this is the fact the CoroutineDispatcher return type
* could not carry, and returning Default instead was what froze the page.
*/
val computeIsParallel: Boolean
}
// jvmMain / androidMain
class JvmDispatchers : AppDispatchers {
override val compute = Dispatchers.Default
override val blocking = Dispatchers.IO // where it genuinely exists
override val computeIsParallel = true
}
// iosMain
class NativeDispatchers : AppDispatchers {
override val compute = Dispatchers.Default
// No blocking pool, and no blocking platform APIs to need one. Pointing this at Default
// would let a blocking call occupy a core-count pool and starve computation — so it is
// Default with a HARD cap, and the cap is the warning.
override val blocking = Dispatchers.Default.limitedParallelism(2)
override val computeIsParallel = true
}
// wasmJsMain
class WasmDispatchers : AppDispatchers {
override val compute = Dispatchers.Default // the event loop; see computeIsParallel
override val blocking = Dispatchers.Default
override val computeIsParallel = false // ← the honest answer
}
And the call site, which now chunks on the one target that cannot offload:
// commonMain/kotlin/feed/FeedRepository.kt
class FeedRepository(
private val api: Api,
private val json: Json,
private val dispatchers: AppDispatchers,
) {
suspend fun loadFeed(): List<Post> {
val raw = api.getFeed() // SUSPENDING — needs no dispatcher at all
return if (dispatchers.computeIsParallel) {
// Off the UI thread, so there is no reason to chunk — parse the whole payload in
// one pass on a worker. Same NDJSON input, different strategy per target.
withContext(dispatchers.compute) {
raw.lineSequence().filter { it.isNotBlank() }.map { json.decodeFromString<Post>(it) }.toList()
}
} else {
// One thread: the only way to keep the page responsive is to yield between
// pieces of work. Slower in total and non-blocking, which is the trade a
// single-threaded target forces and `withContext(Default)` silently did not make.
parseInChunks(raw)
}
}
/**
* Parses incrementally, yielding to the event loop so input is processed between chunks.
*
* This needed a TRANSPORT change to work, which is the part that surprised me. The first
* version called `json.parseToJsonElement(raw).jsonArray` and then chunked the resulting
* elements — but that first call parses the whole document in one synchronous pass, so the
* longest block was the tree parse and the chunking only covered the cheaper
* element-to-Post half. Yielding between chunks cannot help with work that already
* happened before the first chunk.
*
* So the feed endpoint returns NDJSON — one Post per line — and each line is parsed on its
* own. Nothing is parsed before the loop, which is what makes the yield meaningful.
*/
private suspend fun parseInChunks(raw: String): List<Post> {
val lines = raw.lineSequence().filter { it.isNotBlank() }.toList()
val out = ArrayList<Post>(lines.size)
lines.chunked(CHUNK).forEach { slice ->
slice.forEach { out += json.decodeFromString<Post>(it) }
yield() // the whole point: let a tap be handled here
}
return out
}
private companion object { const val CHUNK = 50 }
}
And the output it produced, same 400-item feed, same throttled machine:
$ # Chrome DevTools ▸ Performance
before after
decodeFromString total 38 ms 44 ms ← slower in total
longest main-thread block 38 ms 6 ms
Long Task warnings 1 0
INP 312 ms 48 ms
Six milliseconds longer overall and an interaction latency six times better, which is the trade a single-threaded target actually offers.
How we proved it
That withContext(Dispatchers.Default) on wasm does not leave the thread — the measurement that
explained why the code looked right:
// a probe, logged on three targets
withContext(Dispatchers.Default) { log("parse on: ${currentThreadName()}") }
jvm parse on: DefaultDispatcher-worker-3
ios parse on: DefaultDispatcher-worker-1
wasmJs parse on: main ← the same thread it was called from
That the first two fixes changed nothing measurable. Both read as improvements and neither is:
longest main-thread block
Dispatchers.Default (as shipped) 38 ms
Dispatchers.Unconfined 41 ms ← no suspension at all
Default.limitedParallelism(4) 38 ms ← limiting 1 to 4 is 1
chunked + yield() 6 ms
That the blocking/compute split matters on more than wasm. The iOS cap was added after a
measurement nobody had asked for — pointing blocking at an uncapped Default and running six
concurrent file reads:
blocking = Dispatchers.Default compute starved: parse latency p99 680 ms
blocking = Default.limitedParallelism(2) parse latency p99 94 ms
Six blocking reads on a core-count pool leave nothing for computation. The original single alias could not express the distinction, so this was latent on iOS the whole time and only visible once the two kinds of work had separate names.
That computeIsParallel is load-bearing and not documentation. Deleting the branch and keeping
everything else, on wasm:
with the branch longest block 6 ms
without it longest block 38 ms ← back to the original defect
That the chunk size is a real knob and 50 is not special. Measured, since an arbitrary constant in a fix invites the question:
CHUNK = 10 longest block 2 ms total 71 ms
CHUNK = 50 longest block 6 ms total 44 ms
CHUNK = 200 longest block 19 ms total 39 ms
Fifty was chosen for a 6 ms block, comfortably inside a frame. A surface with stricter input latency would take 10 and pay the 27 ms.
What I cannot tell you is whether a web worker would be better. It almost certainly would for a parse this size, and we did not build it — the string-in/objects-out boundary means the parse cannot simply move, and one feed did not justify the architecture. So the chunking is the right fix for this cost and the wrong fix if the parse were ten times larger.
And the fix needed the server to change, which is worth separating from the dispatcher story because it is the part that does not generalise cleanly. Chunking a parse only works if the input can be divided before any of it is parsed; a single JSON document cannot, because building the element tree is one synchronous pass and it is most of the cost. NDJSON made the payload divisible. If the endpoint had not been ours to change, the honest options were a web worker or accepting the block — not a chunked loop over an already-parsed tree, which is what the first version was and which moved the longest block by almost nothing.
And an honest asymmetry in the design. blocking on wasm returns Dispatchers.Default, which
is the event loop, which means a genuinely blocking call there would freeze the page exactly as
before. The honest version would be to make blocking nullable and force every caller to handle
its absence — we did not, because there are no blocking APIs reachable from wasm in our code, and a
nullable dispatcher at eleven call sites to guard against a call that cannot be written felt like
the wrong trade. That is a judgement, and if we ever add a wasm path that can block, it is wrong.

What this generalizes to
A platform resource's name is not portable even when something similar exists everywhere, and aliasing it per target produces a value that is plausible on each platform and wrong on at least one. The move that works is to name the abstraction after the work rather than the resource — compute, blocking, UI — and then to notice that the renaming forces you to say which kind of work you actually had. Here it turned out to be two kinds collapsed into one call, and separating them found a latent iOS problem nobody had been looking for.
Related
- Dispatchers.Main throws on a target you forgot to configure — the same name-versus-resource problem for the dispatcher that fails loudly instead of quietly.
- advanceUntilIdle drained a queue your code was never on — why injecting the dispatcher is also what makes the code testable.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".