When the host doesn't speak your OS
You built a data pipeline in Rust that processes gigabytes of logs in seconds. Now your team wants to run that pipeline inside a container orchestration system that only accepts WebAssembly modules, or as a plugin in a design tool written in C++. You can't just ship a Linux binary. The host environment doesn't speak your OS's dialect. You need a universal adapter that lets your code request files, time, and randomness without knowing whether the host is a browser, a server, or a microcontroller. That adapter is WASI.
WASI stands for WebAssembly System Interface. It is the standardized bridge between your Rust code and the runtime that executes it. Without WASI, your WebAssembly module is a sealed box. It can compute, but it cannot read a file, check the clock, or generate a random number. WASI defines a set of functions that the host must implement. Your code calls these functions through a thin ABI layer. The host decides what happens. The host might map a virtual file system, or it might deny access entirely. This separation is the point. Your code stays portable. The host stays in control.
WASI is the contract, not the engine
WebAssembly (WASM) is the instruction set. It is the engine. WASI is the exhaust pipe and fuel line. It is the standardized way for WASM modules to talk to the outside world. Think of WASI like a standardized power plug. Your code is the appliance. The host is the wall socket. The plug shape is defined by the WASI specification. As long as the host implements the WASI interface, your appliance works. You don't care if the socket is in a house, a car, or a spaceship. You just care that the plug fits and electricity flows.
In Rust, this contract is enforced by the target triple. The compiler generates different code depending on which target you select. The target tells the compiler which system calls are available. If you compile for a target that supports WASI, the compiler emits calls to WASI functions. If you compile for a target that does not, the compiler assumes those functions don't exist and rejects your code.
The standard target for WASI is wasm32-wasip1. The wasi part indicates the system interface. The p1 stands for "preview 1", which is the current stable version of the WASI specification. There are other targets for other environments, but wasm32-wasip1 is the one you use when you need file I/O, environment variables, random numbers, or clock access in a WASM module.
Minimal example: Hello and a file read
Start with a simple program that prints a message and reads a file. This proves that stdout works and that the file system is accessible through WASI.
/// Reads a file and prints its length.
/// Requires a file named `input.txt` in the current directory.
fn main() {
// WASI supports stdout, so println works normally.
println!("Hello from WASI!");
// std::fs uses WASI syscalls under the hood.
// This will fail if the host hasn't mapped the file.
let content = std::fs::read_to_string("input.txt")
.expect("Failed to read input.txt");
println!("Read {} bytes", content.len());
}
Build this for the WASI target. You need to add the target to your toolchain first.
rustup target add wasm32-wasip1
cargo build --target wasm32-wasip1
The output is a .wasm file in target/wasm32-wasip1/debug/. You cannot run this file directly with the OS. The OS doesn't know how to execute WebAssembly. You need a WASI runner. The community standard for local testing is wasmtime.
# Install wasmtime if you haven't already
curl https://wasmtime.dev/install.sh -sSf | bash
# Create a dummy input file
echo "WASI is the plug." > input.txt
# Run the module. The --dir flag grants access to the current directory.
wasmtime run --dir . target/wasm32-wasip1/debug/app.wasm
The --dir . flag is crucial. WASI is sandboxed by default. The host does not give you access to the entire file system. You must explicitly grant access to directories. If you run wasmtime run without --dir, the read_to_string call will fail with a permission error. This is a feature, not a bug. It ensures your code can't accidentally read sensitive files.
Trust the runner. It enforces the sandbox. If you can't read the file, the host hasn't mapped it.
Walkthrough: Build, run, and the runner gap
The workflow for WASI development has a specific gap that trips up newcomers. cargo run does not work for WASI targets. cargo run tries to execute the binary using the OS's native loader. On WASI, the binary is a WebAssembly module. The loader fails. You must use a runner like wasmtime, wasm32-wasip1, or a custom host.
This means your development loop changes. You build with cargo build --target wasm32-wasip1, then run with wasmtime run. You can speed this up by configuring cargo to use a runner automatically. Add this to your .cargo/config.toml:
[target.wasm32-wasip1]
runner = "wasmtime run --dir ."
Now cargo run --target wasm32-wasip1 works. The --dir . in the runner config grants access to the current directory for every run. Adjust the flags based on your needs. If your code reads from a specific config directory, add --dir ./config to the runner.
Convention aside: wasmtime is the de facto reference implementation for WASI. If you are debugging WASI behavior, test with wasmtime first. If it works in wasmtime, the issue is likely with the host you are targeting, not your Rust code.
Realistic example: Config loader with serde
Real applications rarely just read text files. They parse structured data. Rust's ecosystem works well with WASI. Crates like serde and serde_json are pure Rust logic. They don't depend on system calls. They work identically in WASI as they do on native targets. The I/O layer is the only part that crosses the WASI boundary.
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
struct Config {
/// Maximum number of retries for network requests.
max_retries: u32,
/// Feature flags enabled in this environment.
features: Vec<String>,
}
/// Loads configuration from a JSON file.
/// Returns the parsed config or an error if the file is missing or invalid.
fn load_config(path: &str) -> Result<Config, Box<dyn std::error::Error>> {
// std::fs::read_to_string uses WASI path_open and path_read.
let content = std::fs::read_to_string(path)?;
// serde_json is pure logic. No syscalls involved.
let config: Config = serde_json::from_str(&content)?;
Ok(config)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = load_config("config.json")?;
println!("Max retries: {}", config.max_retries);
println!("Features: {:?}", config.features);
Ok(())
}
Add serde and serde_json to Cargo.toml.
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Build and run. The serde deserialization happens entirely inside the WASM module. The only WASI calls are the file reads. This separation keeps your code fast. The host handles the I/O overhead. Your code focuses on the logic.
Treat WASI calls as external dependencies. They can fail. They can be slow. They can be denied. Handle the errors.
Pitfalls: getrandom, threads, and the 1.94.1 fix
WASI development has a few sharp edges. The most common is the getrandom crate. Many crates depend on getrandom for cryptographic randomness. rand, uuid, and crypto libraries all use it. On native targets, getrandom uses OS syscalls. On WASI, it must use the WASI random_get syscall.
If your dependency tree pulls in getrandom but the WASI feature is missing, you get a link error or a runtime trap. Modern versions of getrandom detect wasm32-wasip1 automatically and enable the WASI backend. If you see a link error mentioning getrandom, check your dependency versions. You likely have an old transitive dependency that hasn't updated its feature detection. Run cargo update to pull in newer versions. If the error persists, check the Cargo.lock for getrandom and ensure it has the wasi feature enabled.
Another pitfall is multithreading. The base wasm32-wasip1 target does not support threads. If you call std::thread::spawn, the compiler might accept it, but the runtime will trap. You must use the wasm32-wasip1-threads target. This target enables the WASI threads proposal.
rustup target add wasm32-wasip1-threads
cargo build --target wasm32-wasip1-threads
Rust 1.94.1 includes a critical fix for std::thread::spawn on this target. Earlier versions could panic immediately when spawning a thread due to a stack initialization bug. If you are using threads, pin your toolchain to 1.94.1 or newer. If you are on an older compiler, you might hit this wall. The error manifests as a panic in the thread spawn function, often with a confusing message about stack alignment.
Compiler errors can also appear if you mix targets. If you compile a library for wasm32-wasip1 and try to link it with a binary for wasm32-wasip1-threads, you might get mismatched type errors or link failures. Ensure all crates in your dependency tree use the same target.
Don't fight the target triple. If you need threads, switch to the threads target. There is no flag to enable it later.
Decision: Picking the right target
Rust provides several WebAssembly targets. Choose based on the capabilities of your host environment.
Use wasm32-wasip1 when you need file I/O, environment variables, random numbers, or clock access, and your host supports the WASI preview 1 interface. This is the standard target for serverless functions, plugin systems, and sandboxed tools.
Use wasm32-wasip1-threads when your code calls std::thread::spawn or uses tokio with the multi-threaded runtime, and your host supports the WASI threads proposal. This target is required for concurrent workloads. Ensure your toolchain is 1.94.1 or newer to avoid thread spawn bugs.
Use wasm32-unknown-unknown when you are targeting a browser environment where system calls are forbidden or handled by JavaScript glue code. This target has no standard library support for I/O. You must use #![no_std] or provide your own implementations for any system interactions. Reach for this target only when the host explicitly forbids WASI or provides its own custom ABI.
Pick the target that matches the host's capabilities. Mismatching them leads to traps you can't debug.