background-shape
Rust to WebAssembly at the Edge, Wasmtime and WASI in 2024
March 18, 2024 · 6 min read · by Muhammad Amal programming

TL;DR — WebAssembly outside the browser finally feels coherent in 2024. Wasmtime 18 ships stable support for WASI Preview 2 and the component model, which means Rust crates can compile to portable .wasm components that compose like libraries. The result is smaller, faster, and more secure than container-based edge functions for a real subset of workloads.

I’ve been writing about server-side Rust for a while, but the edge story keeps creeping into client work. Customers want functions that boot in single-digit milliseconds, run with no FS or network access by default, and ship as a single sandboxed artifact. That’s the WebAssembly pitch, and as of March 2024 it’s no longer aspirational.

This post is what I tell developers who already know Rust and want to understand the current state of the WASM toolchain — what to build with, what to deploy on, and where the sharp edges still live. If you’re coming from a Tokio-flavored server background, my notes on the 2024 production Rust stack cover the surrounding tooling that still applies on the host side.

The 2024 Picture in Three Sentences

Wasmtime is the reference runtime; it’s stable, embeddable, and Bytecode Alliance-stewarded. WASI Preview 2 (P2) replaces the ad-hoc Preview 1 syscall surface with a typed interface defined by WIT (WebAssembly Interface Types). The component model lets you compose .wasm modules with explicit imports/exports across language boundaries.

In practice: you write Rust, target wasm32-wasi (or wasm32-wasip1 in nightly nomenclature), use cargo-component to produce a component, and run it under wasmtime or any other P2-compatible host like wasmCloud or Fermyon Spin.

Building Your First Component

Start with the toolchain. As of Rust 1.76, the wasm32-wasi target is stable, and cargo-component is the canonical way to produce P2 components from a Rust crate.

rustup target add wasm32-wasi
cargo install cargo-component --version 0.7
cargo install wasmtime-cli --version 18.0.2

A minimal greeter component looks like this. The wit/ directory holds the interface; src/lib.rs implements it.

// wit/world.wit
package muhammadamal:greeter@0.1.0;

interface api {
    record name { first: string, last: string }
    greet: func(n: name) -> string;
}

world greeter {
    export api;
}
// src/lib.rs
#[allow(warnings)]
mod bindings;

use bindings::exports::muhammadamal::greeter::api::{Guest, Name};

struct Component;

impl Guest for Component {
    fn greet(n: Name) -> String {
        format!("Hello, {} {}", n.first, n.last)
    }
}

bindings::export!(Component with_types_in bindings);
# Cargo.toml
[package]
name    = "greeter"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wit-bindgen-rt = "0.20"

[package.metadata.component]
package = "muhammadamal:greeter"
target  = { path = "wit" }

Build and inspect it.

cargo component build --release
# target/wasm32-wasi/release/greeter.wasm is a real component

wasm-tools component wit target/wasm32-wasi/release/greeter.wasm

The output WIT will match what you wrote. That round-trip — types in, types out — is the thing the component model gives you that raw modules never did.

Hosting a Component from Rust

The host side is where wasmtime 18 earns its keep. You instantiate the component, pass a typed value across the boundary, and get a typed result back. No *const u8 and len dance.

// Cargo.toml on the host side
// wasmtime = { version = "18.0", features = ["component-model"] }

use wasmtime::component::{bindgen, Component, Linker};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::preview2::{self, WasiCtx, WasiCtxBuilder, WasiView};

bindgen!({
    path: "wit",
    world: "greeter",
});

struct Host {
    table: preview2::ResourceTable,
    wasi:  WasiCtx,
}

impl WasiView for Host {
    fn table(&mut self)    -> &mut preview2::ResourceTable { &mut self.table }
    fn ctx(&mut self)      -> &mut WasiCtx                 { &mut self.wasi }
}

fn main() -> anyhow::Result<()> {
    let mut cfg = Config::new();
    cfg.wasm_component_model(true).async_support(false);
    let engine = Engine::new(&cfg)?;

    let component = Component::from_file(&engine, "greeter.wasm")?;
    let mut linker = Linker::new(&engine);
    preview2::command::sync::add_to_linker(&mut linker)?;

    let host = Host {
        table: preview2::ResourceTable::new(),
        wasi:  WasiCtxBuilder::new().inherit_stdio().build(),
    };
    let mut store = Store::new(&engine, host);

    let (bindings, _) = Greeter::instantiate(&mut store, &component, &linker)?;
    let api = bindings.muhammadamal_greeter_api();
    let out = api.call_greet(&mut store, &Name {
        first: "Muhammad".into(),
        last:  "Amal".into(),
    })?;
    println!("{out}");
    Ok(())
}

Cold-start this binary and you’ll see the component load and execute in well under 10ms on a laptop. That’s the real win: a sandboxed, typed extension point you can ship as a single .wasm file.

What WASI Preview 2 Actually Gives You

P1 was a flat list of POSIX-flavored syscalls. P2 is a tree of typed interfaces, each declared in WIT:

  • wasi:io — streams, polls, error types
  • wasi:filesystem — capability-scoped FS access (no ambient authority)
  • wasi:sockets — TCP/UDP with explicit network grants
  • wasi:http — request/response handler interface
  • wasi:cli — stdio + environment + clocks for command-line apps
  • wasi:clocks, wasi:random

The killer feature is capability-based security. A component declares which interfaces it imports; the host decides which to provide. Want to run untrusted plugin code with no network and no FS? Don’t link those interfaces. The plugin physically can’t touch them.

The official spec lives at wasi.dev and the wasmtime API docs at docs.wasmtime.dev. Both are kept current with what the runtime actually implements.

Targeting wasi:http for Edge Functions

The wasi:http proxy world is the interface most edge platforms have aligned on. Your component exports an incoming-handler and the host calls it per request. Spin and wasmCloud both consume this contract directly.

// wit/world.wit using wasi:http
package muhammadamal:edge@0.1.0;

world edge-fn {
    export wasi:http/incoming-handler@0.2.0;
}
use bindings::exports::wasi::http::incoming_handler::Guest;
use bindings::wasi::http::types::{
    Fields, IncomingRequest, OutgoingBody, OutgoingResponse, ResponseOutparam,
};

struct EdgeFn;

impl Guest for EdgeFn {
    fn handle(_req: IncomingRequest, out: ResponseOutparam) {
        let headers = Fields::new();
        let _ = headers.set(&"content-type".into(), &[b"text/plain".to_vec()]);
        let resp  = OutgoingResponse::new(headers);
        let body  = resp.body().expect("body");

        ResponseOutparam::set(out, Ok(resp));

        let stream = body.write().expect("write");
        let _ = stream.blocking_write_and_flush(b"hello from the edge\n");
        drop(stream);
        let _ = OutgoingBody::finish(body, None);
    }
}

bindings::export!(EdgeFn with_types_in bindings);

That same artifact runs on wasmtime locally, on Spin via spin up, and on hosted edge runtimes that implement wasi:http. Portability between runtimes is finally real.

Binary Size and Startup

A naive Rust component lands around 2–3MB. With wasm-opt -Oz and strip, you can drive a simple HTTP handler to 200–400KB. For cold-start sensitive workloads, that matters.

cargo component build --release
wasm-tools strip target/wasm32-wasi/release/edge_fn.wasm -o stripped.wasm
wasm-opt -Oz stripped.wasm -o final.wasm
ls -lh final.wasm

Two more knobs. First, opt-level = "z" in [profile.release] favors size. Second, panic = "abort" removes unwinding tables. Both shave 10–20% with no runtime cost for the kind of code edge functions tend to be.

Common Pitfalls

A handful I keep tripping over.

Mixing P1 and P2. Older crates still target wasi_snapshot_preview1. They run under wasmtime via the preview1-component-adapter, but the adapter adds bloat and quirks. Prefer P2-native crates when available; check wit-bindgen versions.

Async on the host vs. sync in the guest. Wasmtime’s async_support(true) lets host calls yield, but P2 guest code is still effectively single-threaded with no real async story yet. Don’t try to spawn tasks inside the guest — there’s no executor.

Resource handles leak silently. P2 resources (streams, file descriptors) are reference-counted by the host. Forgetting to drop a stream in the guest leaves the host-side handle alive. Be deliberate.

WIT package version drift. WIT packages are semver-tagged. If your component imports wasi:http@0.2.0 and the host implements 0.2.0-rc-2023-12-05, link fails with a baffling error. Match versions exactly until the ecosystem stabilizes.

Debugging is rougher than native. DWARF support exists but is patchy. wasmtime --debug-info plus lldb works on Linux, but for most issues eprintln! is faster than a debugger.

Wrapping Up

The component model and WASI Preview 2 give Rust a genuinely portable, sandboxed compile target with typed boundaries. Wasmtime 18 is the runtime to develop against; everything else aligns to its lead. If you’ve been waiting for the ecosystem to stabilize before betting on WASM at the edge, this is the version where I’d start a new project on it.

The remaining work is largely tooling polish — better debuggers, smaller binaries by default, and broader runtime parity. The contract is set. Build against it.