Embedding wasmtime in a Rust Application

This page answers one task: run WebAssembly modules from inside your own Rust program — to load plugins, execute user-supplied logic, or sandbox a component — using wasmtime as a library rather than as a command-line tool.

Prerequisites

  • [ ] Rust 1.78+ and a binary crate for the host.
  • [ ] wasmtime and wasmtime-wasi crates at matching versions (both 25.x or newer).
  • [ ] A guest module to run: a wasm32-wasip1 program, or a cdylib with plain exported functions.

The five objects every embedding uses

wasmtime’s API maps the stages of running WebAssembly onto five types, and understanding their lifetimes is most of the work. An Engine holds global configuration — compiler settings, enabled proposals, resource limits — and is shared by everything; create one per process. A Module is a compiled module: the result of validating and translating bytes into machine code. It is immutable and thread-safe, so compile once and reuse it.

A Store holds runtime state: instances, memories, tables, and a piece of host data you choose (T). Every instantiation happens inside a store, and everything in a store is dropped together. A Linker maps import names to definitions — host functions, WASI, other instances — and resolves a module’s imports at instantiation. Finally an Instance is a module instantiated in a store, from which you fetch exports.

wasmtime's embedding objects and their lifetimes The Engine is created once per process and shared. Modules are compiled once and reused. A Linker describes how imports are satisfied. Each Store holds the runtime state for one unit of isolation, and Instances live inside a Store. Engine one per process: config, compiler, proposals — Send + Sync Module compiled once, reused for every instantiation — Send + Sync Linker<T> import name → host function, WASI, other exports Store<T> one per isolated run: instances, memories, your host state T Instance exports to call; dropped with its Store

Step 1 — dependencies and a minimal host

[dependencies]
wasmtime = "25"
wasmtime-wasi = "25"
anyhow = "1"
use anyhow::Result;
use wasmtime::*;

fn main() -> Result<()> {
    let engine = Engine::default();
    let module = Module::from_file(&engine, "guest.wasm")?;
    let mut store = Store::new(&engine, ());
    let instance = Instance::new(&mut store, &module, &[])?;

    let add = instance.get_typed_func::<(i32, i32), i32>(&mut store, "add")?;
    println!("2 + 3 = {}", add.call(&mut store, (2, 3))?);
    Ok(())
}

get_typed_func checks the export’s signature once, at lookup, and returns a function you can call with native Rust types. A mismatch — the guest exports (i64, i64) -> i64 — is reported as an error here rather than at call time.

Step 2 — wire in WASI

A guest built for wasm32-wasip1 imports WASI functions. Add them to a linker and give the store a WASI context:

use wasmtime_wasi::preview1::{self, WasiP1Ctx};
use wasmtime_wasi::WasiCtxBuilder;

let mut linker: Linker<WasiP1Ctx> = Linker::new(&engine);
preview1::add_to_linker_sync(&mut linker, |ctx| ctx)?;

let wasi = WasiCtxBuilder::new()
    .inherit_stdout()
    .inherit_stderr()
    .args(&["guest"])
    .preopened_dir("./data", "/data", DirPerms::READ, FilePerms::READ)?
    .build_p1();

let mut store = Store::new(&engine, wasi);
let instance = linker.instantiate(&mut store, &module)?;
let start = instance.get_typed_func::<(), ()>(&mut store, "_start")?;
start.call(&mut store, ())?;

The store’s data type is now the WASI context, and the closure passed to add_to_linker_sync tells the linker how to find it. In a real host the store data is usually your own struct with the WASI context as one field. Capabilities are explicit: this guest can print and read ./data as /data, and nothing else — the model described in granting filesystem access with WASI preopens.

Step 3 — expose host functions to the guest

Host functions are how the guest asks the host for things: a key-value lookup, a log sink, the current user. Define them on the linker with func_wrap:

struct HostState { wasi: WasiP1Ctx, log: Vec<String> }

let mut linker: Linker<HostState> = Linker::new(&engine);
preview1::add_to_linker_sync(&mut linker, |s: &mut HostState| &mut s.wasi)?;

linker.func_wrap("host", "log", |mut caller: Caller<'_, HostState>, ptr: u32, len: u32| -> Result<()> {
    let mem = caller.get_export("memory").and_then(|e| e.into_memory())
        .ok_or_else(|| anyhow::anyhow!("guest has no memory export"))?;
    let mut buf = vec![0u8; len as usize];
    mem.read(&caller, ptr as usize, &mut buf)?;                  // bounds-checked
    caller.data_mut().log.push(String::from_utf8_lossy(&buf).into_owned());
    Ok(())
})?;

The Caller gives the host function access to the store’s data and to the calling instance’s exports, which is how it reads strings out of guest memory. Memory::read checks bounds, so a malicious pointer produces an error rather than a host crash. Returning Err from a host function traps the guest, which is the right response to invalid arguments.

A guest calling a host function The host calls the guest's run export. The guest writes a message into its own linear memory and calls the imported host.log with a pointer and length. The host reads the bytes out of guest memory with a bounds check, records the message in its state, and returns to the guest, which finishes and returns to the host. host (Rust) wasmtime guest module run.call(&mut store, ()) write "loaded 42 rows" into memory host.log(ptr, len) closure with Caller<HostState> memory.read(ptr, len) — bounds-checked Ok(()) → back into the guest

Step 4 — limit what a guest can consume

An embedded guest runs on your host’s CPU and memory. For untrusted code, bound both. Fuel limits instructions; a store limiter bounds memory growth:

let mut config = Config::new();
config.consume_fuel(true);
let engine = Engine::new(&config)?;

let mut store = Store::new(&engine, state);
store.set_fuel(50_000_000)?;                                  // roughly tens of ms of work
store.limiter(|s| &mut s.limits);                             // StoreLimits in your state

// StoreLimitsBuilder::new().memory_size(64 << 20).instances(1).build()

When fuel runs out the guest traps with Trap::OutOfFuel; when it tries to grow memory past the limit, memory.grow returns -1 and the guest’s allocator reports out-of-memory. Both are recoverable from the host’s point of view. Epoch interruption is a cheaper alternative to fuel for wall-clock deadlines; the trade-offs are covered in limiting plugin CPU and memory use.

Step 5 — cache compiled modules

Compiling a large module takes from tens of milliseconds to seconds. For a host that starts often — a CLI tool, a serverless function — serialise the compiled code once and load it directly next time:

// at build or install time
let bytes = engine.precompile_module(&std::fs::read("guest.wasm")?)?;
std::fs::write("guest.cwasm", bytes)?;

// at startup — skips compilation entirely
let module = unsafe { Module::deserialize_file(&engine, "guest.cwasm")? };

deserialize_file is unsafe because the file is trusted to be wasmtime’s own output, produced with a compatible engine configuration; never load .cwasm files from untrusted sources. Alternatively, enable wasmtime’s built-in compilation cache in the Config, which keys cached artifacts on the module’s hash automatically.

Designing the host state

Most of the decisions in a real embedding are about the T in Store<T>, because that type is what every host function sees. Keep it small and specific to one guest run. A good shape is a struct with the WASI context, the resource limits, a handle to whatever services the guest may use — a database connection pool, a cache, an HTTP client — and per-run output such as collected logs or metrics. Host functions then reach exactly those services through caller.data_mut() and nothing else.

Two mistakes recur. The first is putting global, shared state directly into T — for example the whole application’s configuration or a mutable map shared between requests. Because each store owns its T, sharing means wrapping things in Arc and locks, and it becomes easy for one guest’s actions to leak into another’s run. Pass shared services as Arc handles with narrow interfaces instead, so a guest can call get(key) but cannot reach the underlying map.

The second mistake is doing expensive setup per store. Building a WASI context is cheap; opening a database connection is not. Create long-lived resources once, outside the store, and hand each store a cheap clone of a handle. With that structure, the per-request work is: create a store with fresh state, instantiate the precompiled module, call the entry export, and drop the store. That loop is what makes embedded WebAssembly practical for request-per-instance hosting, and it is the same loop edge platforms run internally, as discussed in cold start characteristics of server-side Wasm.

Expected output

For a guest that reads a CSV from /data and logs through the host function:

$ cargo run --release
guest log: loaded 42 rows from /data/input.csv
guest log: wrote summary
fuel consumed: 3184211

Gotchas

  • unknown import: wasi_snapshot_preview1::fd_write. WASI was not added to the linker, or the guest was instantiated with Instance::new instead of linker.instantiate.
  • store used with function from another store. Exports belong to the store that created them. Look them up again for each new store.
  • Every request recompiles the module. Module::new was called per request. Compile once at startup and share the Module across threads; it is cheap to clone.
  • Wrong signature in get_typed_func. The guest exports i64 where the host asked for i32, or uses multi-value returns. Inspect exports with module.exports() to see real signatures.

Performance note

On a 2.1 MB guest, Module::from_file took 640 ms with the optimizing compiler; deserialising the precompiled .cwasm took 4 ms. Instantiating from the compiled module took 35 µs, which makes a fresh store per request — the strongest isolation — affordable even at high request rates.

Cost of each embedding step for a 2.1 MB guest Wall-clock time for compiling the guest from bytes, loading it from a precompiled cwasm file, and instantiating it in a new store. milliseconds (log-like spread) Module::from_file (compile) 640 ms Module::deserialize_file 4 ms instantiate in new Store 0.0 ms

Frequently Asked Questions

Should each request get its own Store? For untrusted or stateful guests, yes — it is the unit of isolation, and with a precompiled module it costs microseconds. For trusted, stateless guests, reusing an instance avoids even that cost.

Can host functions be async? Yes. Enable async_support in the Config and use func_wrap_async and call_async; the guest suspends while the host awaits. That is how HTTP hosts call out to databases without blocking a thread.

How do I run components instead of core modules? Use the wasmtime::component API with bindgen! to generate typed host bindings from a WIT file; see running components in wasmtime.

Is wasmtime the only choice? Wasmer and WasmEdge offer similar embedding APIs; the comparison is in choosing between wasmtime, wasmer and WasmEdge.

← Back to WASI Target Builds & Runtimes