Kotlin Coroutines on wasmJs: why there is no `runBlocking` (and why “hacks” are risky)
Cheat sheet
runBlockingis a thread-blocking bridge.- It exists only for targets that are built from the
concurrentsource set. js/wasm*are built fromjsAndWasm*source sets, so they don’t getrunBlocking.- On JS (and wasmJs hosted by JS) you cannot block the single event loop and still let async callbacks run.
- Background reading: Kt. Academy — Coroutines in other languages
1) The build layout makes it explicit
In kotlinx.coroutines, the source set graph is documented directly in
kotlinx-coroutines-core/build.gradle.kts:
/* ==========================================================================
Configure source sets structure for kotlinx-coroutines-core:
TARGETS SOURCE SETS
------------------------------------------------------------
wasmJs \------> jsAndWasmJsShared ----+
js / |
V
wasmWasi --------------------> jsAndWasmShared ----------+
|
V
jvm ----------------------------> concurrent -------> common
^
ios \ |
macos | ---> nativeDarwin ---> native ---+
tvos | ^
watchos / |
|
linux \ ---> nativeOther -------+
mingw /
========================================================================== */
Key point: Targets that support runBlocking share the concurrent source set (jvm, native*).
Targets like js, wasmJs, and wasmWasi share jsAndWasm*, where blocking builders are intentionally absent.
2) Where runBlocking actually lives
In concurrent/src/Builders.concurrent.kt
it is declared as an expect function:
public expect fun <T> runBlocking(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> T
): T
Then each “blocking-capable” platform provides its own actual implementation.
Native runBlocking: the essential mechanics
See native/src/Builders.kt (simplified):
val eventLoop: EventLoop? = ...
val newContext: CoroutineContext = ...
val coroutine = BlockingCoroutine<T>(newContext, eventLoop)
coroutine.start(CoroutineStart.DEFAULT, coroutine, block)
return coroutine.joinBlocking()
What matters:
- It selects/creates an EventLoop for the blocked thread.
- It starts the coroutine.
- It blocks the thread in
joinBlocking()until completion, while pumping the event loop manually.
3) Minimum about EventLoop (only what we need here)
Blocking builders depend on the ability to process queued work while the thread is blocked.
In common/src/EventLoop.common.kt:
open fun processNextEvent(): Long {
if (!processUnconfinedEvent()) return Long.MAX_VALUE
return 0
}
So runBlocking is not just “wait”; it is “wait and keep progressing coroutine work”.
4) Why “blocking” is still possible on Android (even with one UI thread)
Even if you think “my Android app is single-threaded”, it still runs on a real OS thread. That thread can be parked / blocked, and other threads (timers, I/O, Binder, etc.) can still run.
Also, Android’s main thread is built around a message loop (an event loop). The core loop is literally an
infinite loop in Looper.loop().
Sources:
Key fragment from Looper.loop():
for (;;) {
if (!loopOnce(me, ident, thresholdOverride)) {
return;
}
}
On Android, “one thread” does not mean “no event loop”. It means: “one thread running an event loop”.
Important: Calling runBlocking on the main/UI thread is still a bad idea.
It freezes the Looper, stops message processing, and can lead to ANR (Application Not Responding).
5) Why this cannot exist on wasmJs
On wasmJs (and Kotlin/JS), coroutine resumptions often come from JS callbacks (Promise microtasks, timers, I/O). These callbacks run only when control returns to the JS event loop.
If you block the main thread, you stop the loop, so the coroutine cannot resume.
Minimal JS deadlock example:
function fakeRunBlocking(promise) {
let done = false;
promise.then(() => done = true);
while (!done) {} // blocks the event loop -> then() never runs
}
This is why JS frameworks “wait” by returning a Promise, not by blocking a thread.
6) About “runBlocking for all targets” libraries
There are small community libraries that try to provide runBlocking for JS/WASM.
Example: com.javiersc.kotlinx:coroutines-run-blocking-all.
One JS/WASM implementation is found in
RunBlocking.kt in run-blocking-kmp (simplified):
public actual fun <T> runBlocking(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T,
): T = GlobalScope.promise(context) { block() } as T
Why this is dangerous
- It lies about the return type:
Promise<T>is cast toT. This breaks type safety. - It returns immediately: It does not actually wait.
- It uses
GlobalScope: This breaks structured concurrency (harder cancellation, easier leaks).
7) Your runBlockingAll: better idea, but still not a solution
A simplified version of a common attempt to "fix" this:
actual fun <T> runBlockingAll(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T
): T {
var out: Result<T>? = null
val continuation: Continuation<T> = Continuation(context) { out = it }
block.startCoroutine(receiver = GlobalScope, completion = continuation)
return out!!.getOrThrow()
}
Why it is a bit better
- It does not return a Promise disguised as
T. - If
blockcompletes immediately (no suspension), it really returnsT.
Why it is not a solution
- If the coroutine suspends,
outis stillnull, so it crashes onout!!. - It still uses
GlobalScope. - You still can’t “block until resume” on JS/WASM without freezing the event loop.
The verdict:
It can “unwrap” only non-suspending coroutines.
It cannot turn async work into sync work on JS/WASM.
8) Conclusion: don’t overuse runBlocking (even where it exists)
Even on platforms where runBlocking exists (JVM/Android/Native), it should be used rarely.
- It blocks a thread.
- On Android, using it on the main/UI thread can freeze the app (no Looper messages → ANR risk).
- It makes code harder to maintain and easier to deadlock.
In app code, prefer launching coroutines from a proper scope:
- Android UI layer: use lifecycle scopes like
viewModelScope. - Lower layers / non-Android: create your own scope and control its lifetime. For example:
val scope = CoroutineScope(
SupervisorJob() + Dispatchers.Default + coroutineExceptionHandler
)
The best long-term strategy is to avoid runBlocking in your business logic.
If you later port your app/library to more platforms (especially web / wasmJs), you will have much less work,
because you won’t need to migrate blocking calls to suspend and async flows.