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.
- [ ]
wasmtimeandwasmtime-wasicrates at matching versions (both 25.x or newer). - [ ] A guest module to run: a
wasm32-wasip1program, or acdylibwith 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.
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.
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 withInstance::newinstead oflinker.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::newwas called per request. Compile once at startup and share theModuleacross threads; it is cheap to clone. - Wrong signature in
get_typed_func. The guest exportsi64where the host asked fori32, or uses multi-value returns. Inspect exports withmodule.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.
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.
Related
- Building a plugin host with Extism — a higher-level layer over the same ideas.
- Designing a Wasm plugin interface — what to put in the import and export lists.
- Running Wasm modules with the wasmtime CLI — the same engine from the command line.
- Tracing Wasm requests with OpenTelemetry — instrumenting the host around guest calls.
← Back to WASI Target Builds & Runtimes