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.
Prerequisites
- [ ] Rust 1.78 or newer (
rustc --version) — earlier versions name the targetwasm32-wasi - [ ]
wasmtime20+ to run the result (wasmtime --version) - [ ]
wabtforwasm-objdump, to read the import and export sections - [ ] A crate to build — a binary crate for a command, or a
cdylibfor 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.
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.
Related
- WASI target builds & runtimes — the surrounding model and the sandbox rules.
- Running Wasm modules with the wasmtime CLI — invoking and debugging the artifact you just built.
- Rust to Wasm compilation guide — the browser-targeted pipeline for the same crate.
- Reducing Wasm bundle size with wasm-opt — shrinking the result.
← Back to WASI Target Builds & Runtimes