WASI Target Builds & Runtimes
Everything else in this area assumes a browser on the other side of the boundary. WASI — the
WebAssembly System Interface — assumes there is not one. It defines a set of standard imports for the
things a program normally asks an operating system for: opening files, reading the clock, writing to
standard output, reading environment variables. A module compiled for a WASI target declares those
imports, and any WASI-capable runtime supplies them. The result is a .wasm that runs from a command
line, in a serverless environment, or inside another application, with no JavaScript anywhere.
For a full-stack engineer the practical appeal is that the same source can produce two artifacts: a
browser module whose I/O comes from JavaScript glue, and a WASI module that runs under wasmtime in
CI or on an edge runtime. The compilation pipeline is nearly identical — the same LLVM backend, the
same wasm-opt pass — but the target triple changes what the binary imports, and that single
difference determines where it can run. This guide covers producing those builds correctly, running
them, and understanding the sandbox rules that decide what a WASI module is allowed to touch.
Prerequisites
- [ ] Rust with the
wasm32-wasip1target installed (rustup target add wasm32-wasip1) - [ ]
wasmtime20+ orwasmer4+ on yourPATH(wasmtime --version) - [ ] The WASI SDK if you are building C or C++ (
clang --target=wasm32-wasi) - [ ]
wabtforwasm2watandwasm-objdump, to inspect the import list a build produced - [ ] A shell you can run the binary from — WASI modules are command-line programs first
What the WASI target actually changes
The difference between wasm32-unknown-unknown and wasm32-wasip1 is not the instruction set; both
produce the same opcodes for the same arithmetic. What changes is the standard library. Under
unknown-unknown, anything that would touch the outside world is either unavailable or compiles to a
stub, because there is no defined host to ask. Under a WASI target, the standard library is wired to
a documented set of imported functions in a module namespace named after the WASI snapshot, and those
imports appear in the binary’s import section.
That framing explains the most common early confusion. A WASI binary will not instantiate in a browser
without a shim, because the browser supplies none of those imports — you would get a LinkError
naming wasi_snapshot_preview1. Conversely, a browser-targeted binary running under wasmtime will
often work, but println! will produce nothing, because the standard library was compiled without an
implementation behind it. Neither is broken; they are builds for different hosts.
Step-by-step: producing and running a WASI build
-
Install the target. Rust ships WASI targets through
rustup, andwasip1is the one with broad runtime support today:rustup target add wasm32-wasip1 -
Build a normal binary crate. Unlike a browser module, a WASI module usually has a
main:cargo build --release --target wasm32-wasip1 ls -l target/wasm32-wasip1/release/tool.wasm -
Run it. The runtime supplies the WASI imports and executes the start function:
wasmtime run target/wasm32-wasip1/release/tool.wasm -
Pass arguments and environment. Both are part of WASI, so they arrive exactly as a native program would receive them:
wasmtime run tool.wasm -- --input data.bin --verbose wasmtime run --env LOG=debug tool.wasm -
Grant filesystem access explicitly. This is the part that surprises people coming from native development: the module can see nothing by default. A directory must be preopened and mapped:
wasmtime run --dir ./data::/data tool.wasm -- --input /data/input.bin -
Inspect what the binary actually asks for. Before shipping, confirm the import list matches your expectations — an unexpected import is a dependency pulling in more of the system than you intended:
wasm-objdump -x tool.wasm | grep -A 30 '^Import' -
Optimise the same way as any other module. The Binaryen pass is target-independent:
wasm-opt tool.wasm -Oz --strip-debug -o tool.opt.wasm
The capability sandbox: preopens, not paths
WASI’s filesystem model is the part that most changes how you write the program. There is no global filesystem namespace and no ambient authority to open a path. Instead, the runtime hands the module a set of already-open directory handles — preopens — and every path the module opens must be resolved relative to one of them. A path that escapes the preopened directory is rejected by the runtime, not by the module’s own logic.
The practical consequence is that a program written against WASI is unusually easy to sandbox in production: you grant one directory rather than trusting the program’s own path handling. It also means a program that hard-codes absolute paths, or that walks upward from the current directory, needs a small amount of adjustment — usually accepting the working directory as an argument rather than discovering it.
Preview 1 and preview 2
Two snapshots of WASI are in circulation, and knowing which one you are targeting avoids a class of
confusing errors. Preview 1 (wasm32-wasip1) is the widely deployed one: a flat list of
POSIX-shaped syscalls in a module namespace called wasi_snapshot_preview1. It is stable, supported
by every major runtime, and what most tooling still assumes by default.
Preview 2 (wasm32-wasip2) restructures the same capabilities as component-model interfaces
described in WIT, which brings typed, versioned APIs and composition at the cost of newer, less
uniform runtime support. A preview 2 artifact is a component, not a plain module, so tools that
expect a core module will reject it — and that mismatch is the single most common error when people
first switch targets.
The pragmatic advice is to build for preview 1 unless you specifically want the component model, and to keep the target triple in the build command rather than in a default, so which snapshot you produced is always visible. When you do move to preview 2, the same source usually compiles unchanged; what changes is the artifact’s shape and the tools that can consume it.
Optimization and tradeoffs
A WASI build carries the standard library implementation of the I/O it uses, so it is typically
larger than the same code compiled for the browser, where the equivalent work lives in JavaScript
glue. Formatting machinery is the usual surprise — println! pulls in a substantial amount of code —
so a module that logs liberally can be several times the size of one that returns structured data and
lets the caller format it.
The mitigations are the ordinary ones with one addition. opt-level = "z", lto, panic = "abort"
and a wasm-opt -Oz pass all apply unchanged. The addition is to be deliberate about I/O: writing one
formatted line per record is convenient and expensive, while writing a compact binary result and
formatting it in the caller keeps both the code size and the runtime cost down. Where the module is a
long-running service rather than a command, the tradeoff flips — startup size matters less and
ergonomics matter more.
Gotchas and failure modes
unknown import: wasi_snapshot_preview1::fd_write. You are instantiating a WASI build in a host
that supplies no WASI implementation — a browser, or a bare WebAssembly.instantiate in Node without
a shim. Either run it under a WASI runtime, provide Node’s node:wasi imports, or rebuild for
wasm32-unknown-unknown if the browser was the intended target.
failed to find a pre-opened file descriptor. The module tried to open a path with no matching
preopen. Add a --dir mapping, and check that the path the module uses matches the guest side of the
mapping rather than the host side.
The module runs but produces no output. Almost always a browser-target build running under a WASI runtime: the standard library was compiled without an I/O implementation, so the writes go nowhere. Check the target triple in the build command.
file is not a WebAssembly module from a tool that expects a core module. You built a preview 2
component and handed it to something that only understands core modules. Either target wasip1 or use
tooling that understands components.
Nondeterministic output between runtimes. WASI leaves some behaviour host-defined — clock resolution, directory iteration order, environment inheritance. If your program depends on any of them, pin them explicitly rather than relying on a runtime’s defaults.
Verification
Confirm three things before treating a WASI build as ready: that it validates, that its imports are exactly what you expect, and that it behaves identically under a second runtime.
wasm-validate tool.wasm && echo 'structurally valid'
wasm-objdump -x tool.wasm | grep -c 'wasi_snapshot_preview1' # how many syscalls it needs
wasmtime run tool.wasm -- --selftest > a.txt
wasmer run tool.wasm -- --selftest > b.txt
diff a.txt b.txt && echo 'identical across runtimes'
The cross-runtime diff is the check worth automating. A module that behaves differently under two conformant runtimes is depending on something host-defined, and finding that in CI is far cheaper than finding it after a deployment moves.
Where a WASI build earns its place
It is worth being concrete about when this target is the right one, because “WebAssembly outside the browser” covers several quite different situations.
Build tooling and CI steps. A linter, a formatter, a code generator or a schema validator
distributed as a .wasm runs identically on every developer machine and every CI runner, with no
language runtime to install and no platform-specific binaries to publish. The sandbox is a bonus
rather than the point: a tool that can only see the directory you granted it cannot accidentally read
a credential file. For this use the module is a command, arguments come from the shell, and startup
time barely matters.
Edge and serverless compute. Several platforms accept a .wasm and run it per request, with cold
starts measured in single-digit milliseconds because there is no container to boot and no language
runtime to initialise. Here the module is usually a reactor rather than a command — instantiated once
and called repeatedly — and the binary’s size matters because it may be transferred to many edge
locations. This is also where the absence of networking in preview 1 bites hardest, since a request
handler that needs to call another service has to route that through the host.
Plugin systems inside a larger application. An application that wants to run untrusted extensions can embed a runtime and hand each plugin a narrow set of capabilities. The plugin author compiles from whichever language they prefer; the host decides what each one can reach. This is the case where the capability model is doing real work rather than being incidental, and where the interface between host and plugin repays careful design.
Deterministic reproduction of a computation. Because a module’s behaviour depends only on its
inputs and its declared imports, a .wasm plus its input files is a reproducible artifact in a way a
native binary is not. That property is useful for archiving a data pipeline, for reproducing a bug
report exactly, and for any situation where “it worked on that machine” is not an acceptable answer.
Two situations where a WASI build is the wrong tool are worth naming as well. If the destination is a
browser, this is not the target — the imports have no implementation there, and a
wasm32-unknown-unknown build with generated glue is smaller and simpler. And if the program is
dominated by system calls rather than computation — a file-copy utility, a log shipper — the sandbox’s
per-call overhead and the missing syscalls make a native binary the better choice. WASI rewards
compute-heavy work with a narrow I/O surface, which is the same shape that suits WebAssembly
everywhere else.
In this guide
- Compiling Rust to wasm32-wasip1 — the toolchain steps and what the produced binary imports.
- Running Wasm modules with the wasmtime CLI — invoking exports, passing arguments, and reading traps.
- Granting filesystem access with WASI preopens — directory mappings and the errors a missing one produces.
- WASI preview 1 vs preview 2 — what changes, what breaks, and when to move.
- Building C code with the WASI SDK — the clang sysroot route for existing C projects.
Frequently Asked Questions
Can a WASI module run in a browser?
Only with a JavaScript shim that implements the WASI imports it declares. Several exist, and they work
well for filesystem-free modules, but if the module expects real files you are emulating a filesystem
in memory. For browser targets, building for wasm32-unknown-unknown and supplying I/O through
generated ESM glue is simpler
and smaller.
Is WASI slower than a native build? For compute, no — the same instructions run through the same optimising compiler. For I/O, there is a per-call cost to crossing into the runtime’s implementation, which matters for programs doing many small reads and writes. Buffering, exactly as you would natively, removes most of it.
Do I need WASI to run a module outside the browser?
No. A module with no imports runs under any runtime, including wasmtime --invoke. WASI is only
needed when the module wants files, clocks, environment variables or standard streams. Many compute
kernels need none of those and are better built without it — see
setting up a local Wasm runtime for testing.
Which target should a library crate use? Neither, directly — libraries should stay target-agnostic and let the binary crate that consumes them choose. Guard any I/O behind a feature or a trait so the same library can back both a browser module and a WASI command.
Can a WASI module use threads? Preview 1 has no way for a module to spawn a thread by itself — thread creation is the host’s job, and runtimes vary in whether and how they expose it. Shared memory and atomic instructions are available behind the threads proposal, so a host that creates the threads and hands the module a shared memory can run genuinely parallel code. That is a different arrangement from the browser, where a worker creates its own instance over a shared memory; here the embedding decides. If parallelism is central to what you are building, check the specific runtime’s threading support before committing to the design.
How do I distribute a WASI module?
As a file, with its expected arguments and preopens documented. There is no packaging standard
equivalent to a container image, which is both a limitation and the point — the artifact is a single
binary with a declared import list, and everything else about how it runs is decided at launch. For a
tool other people will run, shipping a small wrapper script that supplies the right --dir mappings is
usually kinder than expecting them to work the flags out from an error message.
Is WASI a standard? It is a standards-track effort under the WebAssembly Community Group rather than a ratified standard, which in practice means the preview 1 interface is stable and widely implemented while later snapshots are still evolving. For a project that is a reasonable place to be — the thing you would build against today has broad support — but it does mean checking what a given runtime implements rather than assuming conformance.
Related
- Rust to Wasm compilation guide — the browser-targeted counterpart of this pipeline.
- Choosing between Emscripten, wasm-pack and TinyGo — picking a toolchain before picking a target.
- Cross-platform build automation — building both artifacts in one pipeline.
- Browser sandbox security boundaries — the same capability model, expressed for the browser.
← Back to Compilation Pipelines & Toolchain Setup