Engineering

Wasm has no threads, and that decides your dispatcher

Dispatchers.Default is a single thread in the browser. Code that was merely slow on Android becomes a frozen tab.

MobileByteSensei 8 min read
kmpwasmcoroutines

TL;DR

withContext(Dispatchers.Default) is how shared code says "this is CPU work, move it off the UI thread". On wasmJs there is no other thread to move it to. The dispatcher exists, the call compiles, the work runs — on the one thread that is also painting the page.

Concurrency still works, because coroutines only need suspension points, not threads. Parallelism does not. So any shared function that computes for 300ms between suspension points is a 300ms frozen tab, and no dispatcher choice changes that.

The fix is to make the work yield, or to move it to a Worker. Both are architectural, and both want to be decided in shared code rather than discovered per platform.

The case this post is about

An offline-capable reading app with local search. The shared ranking function:

// commonMain
suspend fun search(query: String, docs: List<Doc>): List<Hit> =
    withContext(Dispatchers.Default) {
        docs.map { doc -> Hit(doc, score(query, doc)) }   // ~8k docs
            .filter { it.score > 0 }
            .sortedByDescending { it.score }
    }

On Android: 180ms on a mid-range device, off the main thread, invisible. On desktop: 60ms. Both correct, both shipped, nobody thought about it again.

The symptom

On wasmJs, typing in the search box freezes the page. Not slows — freezes: the caret stops, the spinner stops spinning, hover states stop responding.

Chrome DevTools → Performance

Task                                  420ms   ← one long task
  ↳ wasm-function[search]             395ms
  ↳ Recomposition                      18ms
Frame                                   —     ← no frames painted during the task

And the console warning, which is the clearest statement of the problem anyone gives you:

[Violation] 'input' handler took 420ms

The spinner is the detail that makes it feel like a paradox. The code did set loading state before dispatching, and Compose did recompose — then the long task started and no frame was painted, so the user never saw the spinner. The UI state was correct and invisible.

Why the obvious fix failed

"Use Dispatchers.IO instead." There is no Dispatchers.IO on wasmJs. The code that compiles in commonMain is using Default, and on wasm Default is backed by the same single-threaded event loop as Main. Swapping dispatchers is choosing between two names for one thread.

"yield() at the top of the block." This was my first attempt and it is a precise illustration of the misunderstanding. yield() once lets the browser paint once — the spinner now appears — and the 395ms task is still 395ms, just moved slightly later. One suspension point does not divide the work; it only relocates it.

"Make it async and await it." async on a single-threaded dispatcher starts a coroutine that runs on the same thread. There is no parallelism to gain. This produced identical timings and a more complicated function.

"Reduce the document count on web." A product compromise that hides the mechanism, and it scales badly — it means every future shared computation needs a per-platform size limit nobody will remember to set.

"Enable wasm threads." The one that feels like the real answer. The WebAssembly threads proposal exists, and shared-memory multithreading requires SharedArrayBuffer, which requires cross-origin isolation headers (COOP/COEP) on every response — including third-party embeds, which then break. Kotlin/Wasm's own support is not something to build a product assumption on today. It is worth knowing about and not worth planning around.

The actual mechanism

Two distinctions that collapse into one on the JVM and come apart on wasm.

Concurrency is interleaving. Parallelism is simultaneity. Coroutines give you concurrency through suspension: at every suspension point, the dispatcher can run something else. That needs one thread. Parallelism needs more than one.

A dispatcher is a scheduler, not a thread supply. Dispatchers.Default on the JVM owns a pool sized to your cores. On wasmJs it schedules onto the browser's event loop. Same API, same semantics for ordering and suspension, and a thread count of one.

The consequence that catches people is that Dispatchers.Default does not fail on wasm — it succeeds, serially. There is no exception, no warning, and no API difference to notice in review. Code written against a thread pool runs correctly and one task at a time, so the only symptom is a duration, and durations are easy to attribute to the browser rather than to the dispatcher.

Which also explains why the usual multiplatform instinct is unhelpful here. There is nothing to put behind an expect/actual: the dispatcher already exists on wasm with the right type and the right semantics for ordering and suspension. What differs is a capability, not an API, and capabilities are invisible to the type system.

So the thing that matters is not which dispatcher you pick but how long you run between suspension points. The browser can only paint between tasks. A coroutine that computes 395ms without suspending is one task, and the frame budget is 16ms.

One event loop with a 16-millisecond frame budget: an unsuspended 395ms ranking computation occupies a single long task during which no frames paint, while the same work split into chunks that yield every few milliseconds interleaves with paint and input, and a Worker moves it off the loop entirely

Which gives the rule:

On a single-threaded target, the unit that matters is the task, not the thread. Shared code is safe if it suspends often enough to keep tasks short — and that is a property of the algorithm, not of the dispatcher it was handed.

The uncomfortable corollary: this was always true on Android too. A 180ms non-suspending block on Dispatchers.Default is fine there only because a different thread paints. The shared code was never well-behaved; Android's extra threads were covering for it.

The fix

Two options, and which one is right depends on how long the work is. Both are decided in shared code.

For work in the tens of milliseconds: chunk it and yield. The computation becomes cooperative, and the platform with one thread gets its paints:

// commonMain
suspend fun search(query: String, docs: List<Doc>): List<Hit> {
    val hits = ArrayList<Hit>(docs.size / 4)
    docs.chunked(CHUNK).forEach { chunk ->
        chunk.forEach { doc ->
            val s = score(query, doc)
            if (s > 0) hits += Hit(doc, s)
        }
        yield()                       // a suspension point every CHUNK docs
    }
    return hits.sortedByDescending { it.score }
}

// Tuned to the frame budget, not to the document count.
private const val CHUNK = 256

Three things to notice:

No withContext at all. On wasm it bought nothing; on Android the yield() points mean the work is interruptible there too, which is a genuine improvement rather than a web-only patch. The caller decides the dispatcher.

yield() also makes cancellation work. A search superseded by the next keystroke now actually stops, because cancellation is checked at suspension points. The frozen-tab fix and the abandoned-work fix are the same change.

CHUNK is tuned against the frame budget. 256 docs at roughly 50µs each is ~13ms, under one frame. The right number is measured, and it is a constant in shared code rather than a per-platform branch.

For work in the hundreds of milliseconds: move it off the loop. Chunking a 2-second computation gives you 125 interleaved frames of a visibly sluggish page. That work belongs in a Worker, and the shared code should say so:

// commonMain
expect suspend fun <T> offMainLoop(block: suspend () -> T): T

// jvmMain / androidMain / iosMain — a real thread exists
actual suspend fun <T> offMainLoop(block: suspend () -> T): T =
    withContext(Dispatchers.Default) { block() }

// wasmJsMain — a Worker, or an honest refusal
actual suspend fun <T> offMainLoop(block: suspend () -> T): T =
    error("wasm: route heavy work through the Worker bridge, not the event loop")

That error is deliberate. It is a loud failure at the call site of any shared function that assumes parallelism, which is exactly the assumption that produced a frozen tab silently. A target that cannot honour the contract should say so at the boundary rather than degrade.

How we proved it

A long task is directly measurable, which makes this one of the easier claims to check honestly.

The browser's own long-task observer, running in the page:

new PerformanceObserver(l => l.getEntries().forEach(e =>
  console.log('long task', Math.round(e.duration), 'ms')
)).observe({ entryTypes: ['longtask'] })
before chunking    long task 395ms          frames painted during search: 0
after  chunking    long task — none         frames painted during search: 27

Zero to twenty-seven painted frames across the same computation is the whole result, and it needed no profiler.

The chunk size was measured rather than guessed, because the first value I picked was wrong in the direction that looks fine:

CHUNK = 2048    longest task 94ms    ← still janky, well over one frame
CHUNK =  512    longest task 27ms    ← better, still drops frames
CHUNK =  256    longest task 13ms    ← under budget
CHUNK =   64    longest task  4ms    total runtime +22%  ← yield overhead dominates

256 is the knee. Worth noting what the last row shows: yielding too often costs real throughput, so "yield more" is not a free improvement.

Two honest limits. Total wall-clock went from 395ms to 430ms — chunking is slightly slower in aggregate, and it is correct anyway, because responsiveness beats throughput for an interactive search. And this was measured in Chrome on a desktop machine; the knee will differ on a phone browser, which is an argument for measuring the constant rather than inheriting mine.

What this generalizes to

A dispatcher is a scheduling policy, not a promise of parallelism. Whenever an API lets you say "run this elsewhere", ask what elsewhere is on each platform. The answer can be a thread pool, one thread, or the caller's thread — and the type signature is identical in all three cases.

Single-threaded platforms expose latency bugs that threads were hiding. The frozen tab was not a wasm defect; it was a shared function that never suspended, running where nothing else could cover. Treat the most constrained target as a correctness check on shared code, not as a special case to work around.

The unit of responsiveness is the task, not the thread. Browser event loops, actor mailboxes, single-threaded runtimes: in all of them, what you owe the system is short tasks. "Which thread" is a different question, and often not the one that matters.

Cooperative code is cancellable code. Chunking for responsiveness gave us working cancellation for free, because both mechanisms need the same thing — frequent suspension points. A computation with no suspension points cannot be interrupted either, on any platform.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS