Embedding Wasmtime: Driving WebAssembly from Rust
A practical tour of the Wasmtime embedding API from Rust: Engine, Store, Linker and Module, host functions, WASI preview 2 components, and fuel and epoch limits for sandboxed plugins.
Published on • October 17, 2026
AI Assistant

Running someone else’s plugin inside your process is a trust decision. The plugin needs to compute, but it must not read your heap, exhaust your CPU, or take the process down when it misbehaves. WebAssembly is the obvious isolation boundary, and Wasmtime is the runtime most often embedded to enforce it.
This post is about the embedding side of Wasmtime: the wasmtime crate as a library inside a Rust host, not the wasmtime CLI. We will walk the object model, define host functions that plugins can call, move real data across the boundary, look at WASI preview 2 and the component model, and put a hard ceiling on untrusted code with fuel and epoch deadlines. If you want the specification side of WASI, that was covered earlier in this series; here we stay in the API.
The Wasmtime book has a good framing for the crate: it splits roughly in two. The root namespace mirrors the JS WebAssembly API for core modules (wasmtime::Module, wasmtime::Func, and so on), while wasmtime::component is the embedding API for the Component Model. Most plugin hosts start with core modules and migrate components when they want typed, cross-language interfaces.
The object model
Five types carry almost all of the work. Getting their lifetimes and ownership right is most of the learning curve.
| Type | What it owns | Lifetime intuition |
|---|---|---|
Engine | Compilation and runtime config | One per process, shareable across threads |
Store<T> | All wasm objects plus your host state T | Short-lived, cheap to create and drop |
Linker | Host function definitions by name | Built once at startup, reused forever |
Module | Compiled code for one .wasm | Expensive to build, safe to share across threads |
Instance | One live instantiation | Derived from a Module through a Linker |
The rules that matter:
Engineis the global context. It is configured throughConfig, and all modules and components belong to one. Everything you do with Wasmtime starts here.Store<T>is the sandbox boundary. Every wasm object - functions, memories, globals - lives inside a store, and every call takes the store as context. TheTtype parameter is how your host state rides along. The docs note aStoreis cheap to create and destroy and does not garbage-collect unused instances internally, so treat it as short-lived, roughly as long as the instances inside it.Linkerresolves imports by string name. Host functions are registered asFn(..) + Send + Syncand normally do not close over mutable state; mutable state belongs inStore<T>and is reached throughCaller.Moduleis the compiled artifact. Compilation is the expensive step, which is why modules can be serialized withModule::serializeand deserialized later - that turns your JIT startup into an ahead-of-time load.Instanceis disposable. Instantiate, grab the exports you need asTypedFunc, call them, drop the store.
Hello, plugin
The canonical first embedding, adapted to a host that carries state:
use wasmtime::*;
const PLUGIN_WAT: &str = r#"
(module
(import "host" "log" (func $log (param i32 i32)))
(memory (export "memory") 1)
(data (i32.const 16) "hello from plugin")
(func (export "compute") (param i32) (result i32)
local.get 0
i32.const 2
i32.mul
local.tee 1
i32.const 16
i32.const 17
call $log
local.get 1)
)
"#;
fn main() -> wasmtime::Result<()> {
let mut config = Config::new();
config.wasm_multi_memory(true);
let engine = Engine::new(&config)?;
let module = Module::new(&engine, PLUGIN_WAT)?;
let mut linker: Linker<HostState> = Linker::new(&engine);
linker.func_wrap("host", "log", |mut caller: Caller<'_, HostState>,
ptr: i32, len: i32| {
let memory = caller
.get_export("memory")
.and_then(|e| e.into_memory())
.expect("plugin exports memory");
let mut buf = vec![0u8; len as usize];
memory.read(&mut caller, ptr as usize, &mut buf)?;
let state = caller.data_mut();
state.log_lines.push(String::from_utf8_lossy(&buf).into_owned());
Ok(())
})?;
let mut store = Store::new(&engine, HostState::default());
let instance = linker.instantiate(&mut store, &module)?;
let compute = instance
.get_typed_func::<i32, i32>(&mut store, "compute")?;
let doubled = compute.call(&mut store, 21)?;
assert_eq!(doubled, 42);
println!("plugin logged: {:?}", store.data().log_lines);
Ok(())
}
Three things are worth copying into your own host:
func_wrapgives the import a module/name pair ("host","log"). The guest’s(import "host" "log" ...)must match exactly, or instantiation fails with an unresolved-import error.- The closure receives
Caller<'_, HostState>, which is how you reach both the plugin’s exports (its memory) and your own state (caller.data_mut()). get_typed_func::<Params, Results>validates the signature at bind time and gives you a cheaper calling convention than the dynamically typedFunc::call.
Passing data across the boundary
Core Wasm only speaks numbers, so strings and buffers cross as (ptr, len) pairs into the guest’s linear memory. The host side of that dance is Memory, which - like every store-connected type - takes the store (or a Caller) as its context. Methods accept anything implementing AsContext or AsContextMut: &Store<T>, &mut Store<T>, &Caller<'_, T>, &mut Caller<'_, T> and the StoreContext wrappers.
The practical pattern:
- The guest allocates and returns a pointer and length, or accepts a pointer and length from the host.
- The host copies bytes in or out with checked accessors (
Memory::read,Memory::write) or a slice overmemory.data_mut(&mut store). - The host never trusts the pointer. A plugin that passes
ptr = 0xFFFF_FFFF, len = 4is a bug you must handle, not a panic.
Two design notes that save pain later:
- Copy at the boundary. Do not hold a
&mut [u8]into linear memory across a wasm call; growth or re-entrancy invalidates it. Copy in, call, copy out. - Re-entrancy is real. A host function called from the plugin can call back into the plugin. Give each direction its own guard (a depth counter in
HostState) if the plugin is untrusted.
For anything richer than byte blobs, the component model is the upgrade path: WIT-defined interfaces, typed records and lists, and resources with explicit ownership, all surfaced through wasmtime::component. The book ships a WASI preview 2 section plus a dedicated “application with plugins” walkthrough, which is the closest thing to a reference plugin architecture.
WASI preview 2 and the component model
The wasmtime crate deliberately does not ship WASI. Filesystem, clocks, random numbers and networking come from the separate wasmtime-wasi crate, whose definitions are added to a Linker before you instantiate anything that imports them. In a plugin host this split is a feature: you decide which WASI worlds a plugin gets, and you can offer none at all.
On the guest side, the modern toolchain target is wasm32-wasip2:
rustup target add wasm32-wasip2
rustc plugin.rs --target wasm32-wasip2
That produces a component directly, which you compile with wasmtime::component::Component and instantiate through wasmtime::component::Linker. Enable the component-model feature (it is on by default in current releases) and expect the async variants of the API if your host functions do I/O - see the next section, because the sync/async split is a compile-time decision, not a per-call one.
A reasonable migration path: core modules plus hand-rolled (ptr, len) calling conventions while the interface is unstable, components with WIT once you have two or three plugins and the interface stops churning.
Sandboxing untrusted plugins
Here is where embedding beats shipping a subprocess. Wasmtime gives you two cheap, per-store enforcement mechanisms, and the crate docs describe their trade-off explicitly.
Epoch interruption is the low-overhead option. Enable Config::epoch_interruption, set a deadline on the store (using the epoch deadline APIs), and have a timer thread call Engine::increment_epoch periodically. Compiled code checks the epoch at low cost; when the deadline is exceeded, execution stops. The docs call the scheduling somewhat nondeterministic, because increments are timer-based.
Fuel is the deterministic option. Enable Config::consume_fuel and give each store a fuel budget; every wasm operation consumes a fixed amount, and running out traps with Trap::OutOfFuel. The instrumentation is significantly more expensive than epoch checks, but the accounting is exact, which matters when plugins are metered per call or you need reproducible behaviour for tests.
The recommended combination for an async host is epoch interruption plus a deadline that yields (Store::epoch_deadline_async_yield_and_update) so that a runaway plugin cannot monopolize Future::poll, wrapped in an overall timeout on the future. Fuel pairs with Store::fuel_async_yield_interval for the deterministic version of the same idea.
CPU is only half the problem. Memory gets its own ceiling through StoreLimits, built with StoreLimitsBuilder and installed on the store, which caps linear memory growth per instance. The ResourceLimiter trait (sync and async variants) lets you enforce those limits with your own policy - for example, sharing a global memory budget across all live plugin instances instead of giving each one its own allowance.
Then the boring-but-essential defenses:
- Validate and pre-approve binaries. Only instantiate modules that came from your build or a signature check.
- Precompile with
Module::serialize. Deserialize at runtime so cold-start cost is bounded and the JIT never runs on an unreviewed artifact. - Cap instantiation. The pooling allocator (
Config::allocation_strategywithInstanceAllocationStrategy::Pooling) bounds instances, memories and tables and makes instantiation reuse cheap; it is the right choice for high-concurrency hosts. - Set feature flags on.
WasmFeatureslets you disable proposals your plugins do not need. - Never expose your own internals through
func_wrap. Every host function is attack surface; keep the surface to what the plugin contract requires.
A minimal plugin host
Pulling it together, the shape of a host that loads a plugin, hands it a restricted set of capabilities, and enforces a budget:
use wasmtime::{Config, Engine, Linker, Module, Store, Trap};
#[derive(Default)]
pub struct HostState {
calls: u64,
}
pub struct PluginHost {
engine: Engine,
linker: Linker<HostState>,
}
impl PluginHost {
pub fn new() -> wasmtime::Result<Self> {
let mut config = Config::new();
config.epoch_interruption(true);
config.consume_fuel(true);
let engine = Engine::new(&config)?;
let mut linker: Linker<HostState> = Linker::new(&engine);
// Capabilities are opt-in: only what the contract needs.
linker.func_wrap("host", "now_ms", |_caller: Caller<'_, HostState>| {
Ok(std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0))
})?;
// If plugins are WASI components, add the definitions from the
// wasmtime-wasi crate to this linker here, world by world.
Ok(Self { engine, linker })
}
pub fn load(&self, wasm: &[u8]) -> wasmtime::Result<Module> {
Module::new(&self.engine, wasm)
}
pub fn run(&self, module: &Module, input: &[u8]) -> anyhow::Result<Vec<u8>> {
let mut store = Store::new(&self.engine, HostState::default());
store.set_epoch_deadline(1);
store.set_fuel(5_000_000)?;
let instance = self.linker.instantiate(&mut store, module)?;
let run = instance.get_typed_func::<(i32, i32), i32>(&mut store, "run")?;
// Copy input into guest memory, call, copy the result back out.
let memory = instance
.get_memory(&mut store, "memory")
.ok_or_else(|| anyhow::anyhow!("plugin exports no memory"))?;
memory.write(&mut store, 0, input)?;
match run.call(&mut store, (0, input.len() as i32)) {
Ok(len) => {
let mut out = vec![0u8; len as usize];
memory.read(&mut store, 0, &mut out)?;
Ok(out)
}
Err(e) if e.downcast_ref::<Trap>() == Some(&Trap::OutOfFuel) => {
Err(anyhow::anyhow!("plugin exceeded its fuel budget"))
}
Err(e) => Err(e.into()),
}
}
}
The details that make this production-shaped rather than a demo: the fuel budget and epoch deadline are set per store before the first call, every failure path is a Result instead of a panic, and the host function surface is a single now_ms. A trap is a value you match on, not a crash - Trap plus WasmBacktrace give you the plugin-side stack for logs.
Checklist
- Create one
Engineper process; treatModuleas expensive andStoreas cheap and disposable. - Keep host functions
Fn(..) + Send + Syncand put mutable state inStore<T>, reached throughCaller. - Validate every
(ptr, len)a plugin sends before touching linear memory, and copy across the boundary instead of holding slices over it. - Add WASI through
wasmtime-wasideliberately; do not give a plugin a world it has not earned. - Decide the sync versus async API family up front - mixing them returns errors rather than working.
- For untrusted code, enable epoch interruption for wall-clock limits and fuel for deterministic metering, and cap memory with
StoreLimitsor a sharedResourceLimiter. - Use the pooling allocator and
Module::serializedeserialization for high-concurrency or cold-start-sensitive hosts. - Treat
Trapas a first-class outcome and log theWasmBacktracewhen one happens.
Sources: Wasmtime embedding API (wasmtime crate), The Wasmtime book, wasmtime repository