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-wasip1 target installed (rustup target add wasm32-wasip1)
  • [ ] wasmtime 20+ or wasmer 4+ on your PATH (wasmtime --version)
  • [ ] The WASI SDK if you are building C or C++ (clang --target=wasm32-wasi)
  • [ ] wabt for wasm2wat and wasm-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.

One source, two import surfaces Compiled for wasm32-unknown-unknown the binary imports only what your own code declares, and standard-library I/O is unavailable. Compiled for wasm32-wasip1 the same source imports the WASI syscall set, which any WASI runtime supplies. the same source file wasm32-unknown-unknown imports: only what you declared no files, no clock, no stdout the host is JavaScript, and it decides println! goes nowhere by default wasm32-wasip1 imports: fd_write, path_open, clock_time_get… a documented, stable syscall surface any WASI runtime can supply them println! writes to standard output Read the difference for yourself with wasm-objdump -x — the import section is the whole story.

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

  1. Install the target. Rust ships WASI targets through rustup, and wasip1 is the one with broad runtime support today:

    rustup target add wasm32-wasip1
  2. 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
  3. Run it. The runtime supplies the WASI imports and executes the start function:

    wasmtime run target/wasm32-wasip1/release/tool.wasm
  4. 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
  5. 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
  6. 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'
  7. 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.

Only what was handed over is reachable The runtime opens the directories named on the command line and passes their handles to the module. Paths resolved inside those directories succeed; an absolute path elsewhere, or a traversal above the preopen, is refused by the runtime before the module sees any data. the host filesystem ./data — preopened /etc — not granted ~/.ssh — not granted the module cannot enumerate any of this the module's view /data → handle 3 every open resolves under a handle ../ above the preopen is refused no path is reachable by name alone --dir ./data::/data no handle exists — nothing to open This is the same capability model as the browser sandbox, expressed in file handles rather than an import object.

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.

Where the extra bytes come from Compared with a browser build of the same logic, a WASI build additionally contains the standard library's file and stream handling and its formatting machinery. The compute code is identical; the difference is entirely in what the binary has to bring with it. browser build your compute code allocator — I/O lives in the JS glue WASI build your compute code file + stream I/O formatting machinery The compute portion is byte-for-byte comparable; everything to its right is the cost of being able to run without a JavaScript host. Returning structured data instead of formatted text removes the largest block, and is usually a better interface anyway. Measure with twiggy before optimising: the ranking of these blocks varies more between projects than you would expect.

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

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.

← Back to Compilation Pipelines & Toolchain Setup