Skip to content
Blog

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.

TypeWhat it ownsLifetime intuition
EngineCompilation and runtime configOne per process, shareable across threads
Store<T>All wasm objects plus your host state TShort-lived, cheap to create and drop
LinkerHost function definitions by nameBuilt once at startup, reused forever
ModuleCompiled code for one .wasmExpensive to build, safe to share across threads
InstanceOne live instantiationDerived from a Module through a Linker

The rules that matter:

  • Engine is the global context. It is configured through Config, 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. The T type parameter is how your host state rides along. The docs note a Store is 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.
  • Linker resolves imports by string name. Host functions are registered as Fn(..) + Send + Sync and normally do not close over mutable state; mutable state belongs in Store<T> and is reached through Caller.
  • Module is the compiled artifact. Compilation is the expensive step, which is why modules can be serialized with Module::serialize and deserialized later - that turns your JIT startup into an ahead-of-time load.
  • Instance is disposable. Instantiate, grab the exports you need as TypedFunc, 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_wrap gives 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 typed Func::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:

  1. The guest allocates and returns a pointer and length, or accepts a pointer and length from the host.
  2. The host copies bytes in or out with checked accessors (Memory::read, Memory::write) or a slice over memory.data_mut(&mut store).
  3. The host never trusts the pointer. A plugin that passes ptr = 0xFFFF_FFFF, len = 4 is 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_strategy with InstanceAllocationStrategy::Pooling) bounds instances, memories and tables and makes instantiation reuse cheap; it is the right choice for high-concurrency hosts.
  • Set feature flags on. WasmFeatures lets 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 Engine per process; treat Module as expensive and Store as cheap and disposable.
  • Keep host functions Fn(..) + Send + Sync and put mutable state in Store<T>, reached through Caller.
  • 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-wasi deliberately; 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 StoreLimits or a shared ResourceLimiter.
  • Use the pooling allocator and Module::serialize deserialization for high-concurrency or cold-start-sensitive hosts.
  • Treat Trap as a first-class outcome and log the WasmBacktrace when one happens.

Sources: Wasmtime embedding API (wasmtime crate), The Wasmtime book, wasmtime repository