Engineering

Dispatchers.IO is not in commonMain, and the reason matters

A browser has no thread pool to offload to.

MobileByteSensei 11 min read
kmpcoroutinesarchitecture

TL;DR

  • Dispatchers.IO does not resolve in commonMain. The workaround that shipped — an expect val returning it per platform — gave wasm Dispatchers.Default, which on wasm is the single UI thread, so a 40 ms JSON parse froze the page on every list load.
  • Dispatchers.IO is 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.

A thread-name probe run on three targets showing that work dispatched to the default dispatcher runs on a worker thread on the JVM and on iOS, and on the main thread on the web target

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.

What each target actually has where the JVM has a large elastic blocking pool, with Kotlin/Native having a core-sized default and no blocking equivalent, and the web having a single thread so dispatching changes when the work runs and never where

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.

Four candidate fixes measured on the longest main-thread block, with two of them reading as improvements and changing nothing, and a latent iOS starvation surfaced only once blocking and compute had separate names

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.


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

Follow by RSS