Running WASI Modules in the Browser
This page answers one task: you have a WebAssembly module built for WASI — a command-line tool, a compiler, a data converter — and you want to run it in a web page without rebuilding it for a different target.
Prerequisites
- [ ] A
wasm32-wasip1module that runs correctly underwasmtime. - [ ] A browser WASI implementation such as
@bjorn3/browser_wasi_shim(npm install @bjorn3/browser_wasi_shim). - [ ] An idea of what the module needs: stdout only, files, clocks, randomness.
Why browsers cannot run WASI modules directly
A WASI module declares imports from a module named wasi_snapshot_preview1 — fd_write for output, path_open for
files, clock_time_get, random_get, proc_exit and a few dozen more. A WASI runtime such as wasmtime provides those
imports by calling the operating system. Browsers implement WebAssembly, but not WASI: there is no
wasi_snapshot_preview1 in a page, so WebAssembly.instantiate fails with a LinkError naming the first missing import.
A shim fills the gap with JavaScript. It implements each WASI function on top of what a browser has: fd_write to file
descriptor 1 appends to a buffer or calls console.log; clock_time_get reads performance.now(); random_get calls
crypto.getRandomValues; and file functions operate on an in-memory file system the page populates. The module neither
knows nor cares that its operating system is a JavaScript object.
Step 1 — check what the module imports
The import list tells you what the shim must provide, and whether a shim is realistic at all:
wasm-objdump -x -j Import converter.wasm | sed 's/.*<- //' | sort -u
wasi_snapshot_preview1.args_get
wasi_snapshot_preview1.args_sizes_get
wasi_snapshot_preview1.clock_time_get
wasi_snapshot_preview1.fd_close
wasi_snapshot_preview1.fd_read
wasi_snapshot_preview1.fd_write
wasi_snapshot_preview1.path_open
wasi_snapshot_preview1.proc_exit
wasi_snapshot_preview1.random_get
That list is typical for a file-converting tool and entirely shimmable. Imports from wasi:sockets, threads, or anything
outside wasi_snapshot_preview1 are a sign the module needs more than a shim can give.
Step 2 — instantiate with the shim
import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from "@bjorn3/browser_wasi_shim";
const input = new Uint8Array(await (await fetch("/samples/data.csv")).arrayBuffer());
const fds = [
new OpenFile(new File([])), // 0: stdin (empty)
ConsoleStdout.lineBuffered((line) => console.log("[stdout]", line)), // 1
ConsoleStdout.lineBuffered((line) => console.warn("[stderr]", line)), // 2
new PreopenDirectory("/work", new Map([["data.csv", new File(input)]])), // 3: a preopened dir
];
const wasi = new WASI(["converter", "/work/data.csv", "/work/out.json"], ["LOG=info"], fds);
const { instance } = await WebAssembly.instantiateStreaming(
fetch("/wasm/converter.wasm"),
{ wasi_snapshot_preview1: wasi.wasiImport }
);
const exitCode = wasi.start(instance);
The file-descriptor array mirrors what a native process sees: 0, 1 and 2 are the standard streams, and preopened
directories follow. PreopenDirectory is the in-memory equivalent of wasmtime --dir, described for real runtimes in
granting filesystem access with WASI preopens.
Arguments and environment are passed exactly as a native shell would pass them.
Step 3 — read the output back
After start returns, files the program wrote are in the in-memory directory:
const dir = fds[3].dir;
const out = dir.contents.get("out.json");
if (exitCode !== 0 || !out) throw new Error(`converter failed with exit code ${exitCode}`);
const json = JSON.parse(new TextDecoder().decode(out.data));
start calls the module’s _start export, which runs main and calls proc_exit at the end. The shim turns
proc_exit into a return value rather than an exception. A module that traps — a panic with panic = "abort", for
example — throws a WebAssembly.RuntimeError out of start, which is worth catching separately from a non-zero exit.
Step 4 — run it in a worker
WASI programs are written to run to completion, synchronously. A conversion that takes two seconds blocks the main thread for two seconds. Move the whole thing into a worker so the page stays responsive:
// converter.worker.js
import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from "@bjorn3/browser_wasi_shim";
const modulePromise = WebAssembly.compileStreaming(fetch("/wasm/converter.wasm"));
self.onmessage = async ({ data: { name, bytes } }) => {
const dir = new PreopenDirectory("/work", new Map([[name, new File(bytes)]]));
const fds = [new OpenFile(new File([])), ConsoleStdout.lineBuffered(() => {}),
ConsoleStdout.lineBuffered((l) => self.postMessage({ log: l })), dir];
const wasi = new WASI(["converter", `/work/${name}`, "/work/out.json"], [], fds);
const instance = await WebAssembly.instantiate(await modulePromise, { wasi_snapshot_preview1: wasi.wasiImport });
const code = wasi.start(instance);
const out = dir.dir.contents.get("out.json")?.data;
self.postMessage({ code, out }, out ? [out.buffer] : []);
};
Compiling the module once and instantiating per job is the pattern from instantiating one module many times: each run gets a clean instance and fresh memory, which suits run-to-completion programs exactly.
Step 5 — decide whether a rebuild is better
A shim is the right tool when the module is a tool you do not want to change — a compiler, a formatter, a converter — or
when the same binary must run on servers and in browsers. It is the wrong tool when the module is your own and is
primarily a browser component. Then building for wasm32-unknown-unknown with wasm-bindgen gives you typed exports,
zero-copy access to memory, and no file-system emulation, as weighed in
choosing a Rust Wasm target triple.
Passing data through an in-memory file and command-line arguments is a fine interface for a tool and a clumsy one for a
library.
What the shim cannot emulate
It is worth being clear about the edges of this approach before committing to it. A shim gives a WASI program files,
clocks, randomness, arguments and standard streams. It cannot give it blocking input: fd_read on stdin must return
immediately with whatever data was prepared in advance, so an interactive program that waits for the user to type will
read end-of-file instead. It cannot give it real sockets, so programs that open network connections fail at the first
sock_* call or at instantiation. It cannot give it threads, because preview 1 WASI threads depend on a host that spawns
instances in parallel, which a simple shim does not do. And it cannot make long computation asynchronous: the program runs
to completion inside one call to start, which is why the worker in step 4 is not optional for anything slow.
Within those limits the approach is remarkably effective. Compilers, linters, formatters, image and document converters, data validators and scientific codes are all run-to-completion programs that read input files and write output files. Many of them can be shipped to the browser as-is, from the same binary that runs in CI, which keeps the browser and the server producing identical results. That consistency is often the real reason to prefer a shim over a dedicated browser build.
Expected output
For the CSV converter, the console shows the program’s stderr lines, and the result is parsed from the output file:
[stderr] reading /work/data.csv
[stderr] 12,408 rows, 9 columns
[stderr] wrote /work/out.json (1.84 MB)
exit code 0
Gotchas
LinkError: import object field 'sock_accept' is not a Function. The module imports something the shim does not implement. Check the import list; some shims omit sockets or polling entirely.- Output never appears. The program writes to stdout without a trailing newline and the shim’s line buffer never flushes. Flush explicitly at exit, or use an unbuffered stdout implementation.
- The page freezes during conversion. The module is running on the main thread. Move it into a worker.
- Large files exhaust memory. The in-memory file system holds input and output in JavaScript memory while the module holds its own copy in linear memory. Expect peak usage of two to three times the data size.
Performance note
A Rust CSV-to-JSON converter processing a 6 MB file took 182 ms under wasmtime and 214 ms in Chrome through the shim —
the extra time was mostly the shim’s JavaScript fd_read and fd_write implementations copying through linear memory in
small chunks. Raising the program’s read buffer from 8 KB to 256 KB cut the browser time to 191 ms by making far fewer
boundary crossings.
Frequently Asked Questions
Can the shim read files the user selects?
Yes. Read the File from an <input type="file"> into a Uint8Array and place it in the preopened directory before
starting the module.
Does this work for WASI preview 2 components?
Not with a preview 1 shim. Use jco to transpile the component into JavaScript, which supplies browser implementations of
the wasi:* interfaces; see generating JavaScript bindings with jco.
Can a WASI module make network requests in the browser? Not through WASI sockets. A module that needs data should receive it as an input file, fetched by the page beforehand.
Can I cache the compiled module between runs?
Yes, and you should: compile once with compileStreaming, keep the WebAssembly.Module, and instantiate it per run. The
browser’s own compiled-code cache also applies when the module is fetched from a stable URL.
Is Node’s node:wasi usable in the browser? No — it depends on Node’s file system. Browser shims are separate implementations of the same interface.
Related
- Building C code with the WASI SDK — producing modules to run this way.
- Reading arguments and environment variables in WASI — what the shim’s
argsandenvfeed. - Streaming file uploads into Wasm memory — handling inputs too large to buffer twice.
- Keeping the UI responsive during long Wasm tasks — the worker pattern in general.
← Back to WASI Target Builds & Runtimes