
Top-level await is a footgun: The Wasm worker edition
Published on:Web workers that load Wasm through an ES6 import with the help of bundler plugins can silently drop messages during startup. This happens because the top level await for Wasm’s asynchronous initialization blocks the worker’s message handler registration, creating a race condition that’s often invisible.
As an example, the web worker module below will drop messages until after a second has passed.
await new Promise(resolve => setTimeout(resolve, 1000));
addEventListener("message", (event) => {
console.log("RECEIVED", event.data);
});
// Main thread:
// const worker = new Worker("./my-worker.js");
// worker.postMessage("hello");
For long-running synchronous code, no messages are dropped as synchronous code doesn’t yield back to the event loop before the event handler is registered.
// This is totally fine, no messages will be dropped
let j = 0;
for (let i = 0; i < 100000; i++) {
j += 1;
}
addEventListener("message", (event) => {
console.log("RECEIVED", event.data);
});
Any top-level asynchronous suspension point is a major red flag in web worker code.
So in a development setup where one can write:
import { my_wasm_fn } from "./my-wasm-module";
addEventListener("message", (event) => {
console.log("RECEIVED", event.data);
});
The same issue will appear, as there is a top level await behind the scenes due to browsers not supporting direct imports of Wasm modules1.
I consider these hidden top-level awaits harmful, as there is zero transparency. Every import must be viewed with suspicion. It does not seem controversial to declare it’s a best practice to have the web worker start listening for messages as soon as it’s created. And anything that gets in the way (ie: top-level awaits) is “bad”.
What are the workarounds? If you are stuck with your current bundler setup, the easiest solution is to transform static imports to be dynamic:
const myWasmPkgTask = import("./my-wasm-module");
addEventListener("message", (event) => {
const { my_wasm_fn } = await myWasmPkgTask;
});
Most likely this will result in additional network fetches in a bit of a waterfall fashion.
An alternative is to have the web worker signal readiness:
import { my_wasm_fn } from "./my-wasm-module"; // top level await
addEventListener("message", (event) => {
});
postMessage("I'm ready for messages now :)");
The race condition is still present but at least callers understand semantics.
The best solution (or at least my favorite), the solution that has been tried and true for the 7 years that I’ve been using Wasm on the web, is to make Wasm initialization explicit.
import init, { my_wasm_fn } from "./my-wasm-module";
import wasmPath from "./my-wasm-module_bg.wasm?url";
const initTask = init({ module_or_path: wasmPath });
addEventListener("message", async (event) => {
await initTask;
my_wasm_fn();
});
While the above is specific to wasm-bindgen
web target and Vite’s explicit URL imports for static assets2, this technique is applicable to most situations as digesting imports is table stakes for any bundler.
When JS package maintainers see what ergonomic hurdles explicit initialization imposes on their users, they aren’t happy.
That’s such a poor user experience and not at all idiomatic in JavaScript3
moving the wiring to the users is not a great DX4
I 100% agree with them, but Wasm is a leaky abstraction and is a strictly worse DX for consumers than a pure JS solution. I’m still all in on Wasm5, but it’s important to recognize one’s weaknesses and be honest with users why there is async initialization.
While I’ve focused thus far on Wasm, pitfalls of top level async is not exclusive to Wasm, just more pronounced, as developers have to wrangle the integration somehow. That typically means googling “vite wasm” and landing on vite-plugin-wasm
and adding two plugins.
But as we’ve seen, our explicit Wasm initialization requires no plugins. Hence, why I’m against adding vite-plugin-wasm
to Vite core6, as I fear that this will cause bad practices.
Case in point: I was reviewing a pull request from a co-worker that contained a significant amount of LLM-generated documentation around Wasm bundling and packaging. It was fascinating to see how wrong it was (and I’m normally a proponent of AI). It strongly advised against explicit initialization.
Maybe it is naive, but this post is an attempt to move the needle, and come out strongly in favor of integrating Wasm through a combination of static asset imports and an asynchronous initialization step. This way, we avoid bundler plugins, configuration, and top level awaits that cause race conditions.
-
https://developer.mozilla.org/en-US/docs/WebAssembly/Guides/Loading_and_running#what_are_the_options ↩︎
-
https://github.com/rollup/plugins/issues/1353#issuecomment-1334612206 ↩︎
-
https://github.com/rollup/plugins/issues/1353#issuecomment-3016065090 ↩︎
-
The WebAssembly value proposition is write once, not performance ↩︎
-
https://github.com/Menci/vite-plugin-wasm/issues/76#issuecomment-3277146820 ↩︎
Comments
If you'd like to leave a comment, please email [email protected]