Compiling Rust to wasm32-wasip1

This guide takes a Rust crate and produces a .wasm binary that runs under a WASI runtime with working standard output, file access and command-line arguments — and shows how to confirm, from the binary itself, that you built what you intended.

The reason this needs its own walkthrough is that the target changes more than the output path. A wasm32-wasip1 build links a different standard library, produces a module with a _start export rather than a set of named exports, and declares imports that only a WASI host can satisfy. Each of those differences has a failure mode attached, and all of them are visible in the artifact if you know where to look.

What a wasip1 build produces A binary crate compiled for wasip1 becomes a command module: it exports _start, imports the WASI syscalls its standard library uses, and is executed rather than called. A library crate compiled for the same target instead exports its own functions and is invoked explicitly. a binary crate — a command export _start import fd_write, args_get, path_open… wasmtime run tool.wasm runs main, then exits a library crate — a reactor export process, memory, alloc… imports only what std actually used wasmtime run --invoke process lib.wasm 3 stays resident, called explicitly Pick deliberately: a command is right for a CLI or a CI step, a reactor for something a host embeds and calls repeatedly. Both are ordinary Rust crates — the difference is whether the crate has a main.

Prerequisites

  • [ ] Rust 1.78 or newer (rustc --version) — earlier versions name the target wasm32-wasi
  • [ ] wasmtime 20+ to run the result (wasmtime --version)
  • [ ] wabt for wasm-objdump, to read the import and export sections
  • [ ] A crate to build — a binary crate for a command, or a cdylib for something a host calls

Step-by-step procedure

1. Install the target

rustup target add wasm32-wasip1
rustup target list --installed | grep wasi

The older triple wasm32-wasi still resolves on recent toolchains but is deprecated in favour of the snapshot-explicit wasm32-wasip1. Use the new name so the snapshot you are targeting is visible in the build command rather than implied.

2. Build a command

For a crate with a main, the default build is all you need:

cargo build --release --target wasm32-wasip1

The artifact lands at target/wasm32-wasip1/release/<crate>.wasm. Nothing about the source has to change: println!, std::fs, std::env::args and std::time all work, backed by WASI imports.

3. Or build a reactor

If the module is something a host embeds and calls, make it a cdylib and export functions explicitly:

# Cargo.toml
[lib]
crate-type = ["cdylib"]
#[no_mangle]
pub extern "C" fn process(value: i32) -> i32 {
    value * 2
}
cargo build --release --target wasm32-wasip1

The result has no _start; the host calls process directly. This is the right shape when the module is a component of a larger program rather than a program in its own right.

4. Read the import list

This is the step that turns “it compiled” into “I know what I built”:

wasm-objdump -x target/wasm32-wasip1/release/tool.wasm | sed -n '/^Import/,/^$/p'

Every line is a capability the module needs from its host. A short list means the module is close to pure computation; a long one means the standard library pulled in more of the system than you may have intended, which is worth understanding before you deploy it somewhere restrictive.

5. Run it

wasmtime run target/wasm32-wasip1/release/tool.wasm -- --verbose

Arguments after -- are passed through to the module and arrive in std::env::args exactly as they would natively. Environment variables need --env, and files need a --dir preopen.

6. Optimise

The Binaryen pass is unchanged from any other target:

wasm-opt target/wasm32-wasip1/release/tool.wasm -Oz --strip-debug -o tool.opt.wasm
wasm-validate tool.opt.wasm && echo valid

Expected output

A successful build and run looks like this, with the import count as the useful diagnostic:

$ cargo build --release --target wasm32-wasip1
    Finished `release` profile [optimized] target(s) in 4.71s

$ wasm-objdump -x target/wasm32-wasip1/release/tool.wasm | grep -c wasi_snapshot_preview1
9

$ wasmtime run target/wasm32-wasip1/release/tool.wasm -- --verbose
reading configuration
processed 1024 records in 12 ms

Nine imports is typical for a program that writes to standard output and reads arguments. A module that also opens files will be closer to fifteen; one that does neither can be zero, which is worth checking, because a module with no WASI imports runs under any runtime at all.

Import count as a rough capability meter A pure compute module imports nothing and runs anywhere. Adding standard output brings in a handful of stream syscalls. Adding filesystem access brings in path and descriptor operations, and each addition narrows the set of hosts that can run the binary unchanged. pure compute 0 imports — runs under any runtime, browser included + stdout ≈ 9 — needs a WASI host + files ≈ 15 — and a preopened directory + clocks, random, env Each step narrows where the binary can run unchanged — which is exactly why the import list is worth checking into review. Keep the target at the edges A library crate holding the computation compiles for any target because it performs no I/O. A thin WASI binary reads files and prints results; a thin browser wrapper exposes the same functions through generated bindings. Both depend on the same core. core library — no I/O compiles for every target unchanged WASI binary crate args, files, stdout browser cdylib wasm-bindgen exports The tests live with the core, where they need no runtime at all — which is the main reason to split it out this way.

Gotchas

error[E0463]: can't find crate for 'std'. The target is not installed. Run rustup target add wasm32-wasip1. If you are in a workspace with a pinned toolchain, install it for that toolchain specifically — rustup +1.82 target add wasm32-wasip1.

unknown import: wasi_snapshot_preview1::fd_write at instantiation. The module is being run by a host with no WASI support. Use wasmtime, or provide Node’s node:wasi imports. This is not a build error; the binary is fine, the host is wrong.

A cdylib build still exports _start. The crate has both a main.rs and a lib.rs, and you built the binary target. Pass --lib, or check which artifact you are pointing the runtime at.

std::fs calls return permission errors under every path you try. No directory was preopened. Add --dir ./data::/data and open paths under /data. This is not a file-permission problem, so changing modes on the host will not help.

Networking does not work. Preview 1 has no socket API. A program that needs the network either targets preview 2, uses a runtime-specific extension, or is restructured so the host does the I/O and passes bytes in.

Performance note

A wasm32-wasip1 release build of a small tool typically lands between 200 KB and 2 MB before optimisation, dominated by standard-library formatting and I/O rather than by your own code. Running wasm-opt -Oz --strip-debug commonly removes 30–40%, and switching from formatted text output to a compact binary result can remove more than that again, because it drops the formatting machinery entirely.

Startup is fast in absolute terms — a few milliseconds under wasmtime for a module of this size — but it is paid per invocation, so a program invoked in a tight shell loop spends most of its time starting. Where that matters, build a reactor and let the host call it repeatedly rather than a command the shell re-instantiates each time.

Keeping the build reproducible

A binary that runs identically everywhere is only useful if it is also built identically everywhere, and three things routinely undermine that. The first is an unpinned toolchain: rustup will happily resolve stable to a newer compiler than the one you tested with, and a compiler bump can change inlining decisions, code size and occasionally floating-point code generation. A rust-toolchain.toml in the repository removes the ambiguity and is read automatically by every cargo invocation.

# rust-toolchain.toml
[toolchain]
channel = "1.82.0"
targets = ["wasm32-wasip1", "wasm32-unknown-unknown"]

The second is the environment leaking into the artifact. Absolute paths from the build machine end up in panic messages and debug information, so two developers produce different binaries from identical source. Remapping the prefix fixes both the reproducibility and the privacy question of shipping local directory names to users:

RUSTFLAGS="--remap-path-prefix=$PWD=/build" \
  cargo build --release --target wasm32-wasip1

The third is optional features resolving differently. A workspace where one crate enables a feature on a shared dependency changes what every other crate gets, and the effect on a Wasm binary is usually size rather than behaviour — which makes it easy to miss. Building with --locked in CI catches a lockfile that drifted, and checking the compressed artifact size into the build log turns a silent increase into something a reviewer can see.

Together these three take a few minutes to set up and make the build a function of the source rather than of the machine. That matters more for WASI targets than for browser ones, because a WASI artifact is frequently the thing you distribute — a CLI tool, an edge function, a plugin — rather than an implementation detail behind a bundler.

Testing without a browser in sight

The other advantage of this target is how ordinary testing becomes. A wasm32-wasip1 build runs under wasmtime, which means cargo test can too, with a runner configured once:

# .cargo/config.toml
[target.wasm32-wasip1]
runner = "wasmtime run --dir=."
cargo test --target wasm32-wasip1

Tests then execute inside the same sandbox the shipped artifact runs in, which catches a class of problem a native cargo test misses entirely: a dependency that reaches for a syscall WASI does not have, a path assumption that only holds outside a preopen, or an integer width difference on a 32-bit target. Running the suite both ways — natively for speed, on the Wasm target before release — costs one extra CI job and removes most of the “works natively, fails as Wasm” surprises.

Frequently Asked Questions

Should I use wasm32-wasip1 or wasm32-wasip2? Preview 1 unless you specifically want the component model. It has the broadest runtime support and produces a core module that every tool understands. The differences between the two are worth reading before switching, because the artifact shape changes.

Can the same crate build for both the browser and WASI? Yes, and it is a good pattern: keep the computation in a library with no I/O, and put the WASI specifics in a thin binary crate. The browser build then targets wasm32-unknown-unknown and gets its I/O from generated glue.

Why is the binary so much larger than my browser build? Because it contains the I/O and formatting code that the browser build leaves to JavaScript. See the size discussion in WASI target builds & runtimes for what to remove first.

← Back to WASI Target Builds & Runtimes