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

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.
Related
- Your shared database needs a driver per platform, and that is the easy half — the same per-platform assumption, for storage
- One Ktor client, four engines, and the timeouts that don't mean the same thing — shared config, per-target enforcement
- Your suspend function becomes a callback in Swift, and cancellation does not survive — cancellation across a boundary
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".