How to Fix: Memory leak w/ parallel component instances
Fixing Wasmtime Memory Leaks with Parallel Component Instances
If memory usage keeps growing while running multiple parallel component instances in Wasmtime, the bug is usually not a traditional Rust heap leak. In practice, it is most often caused by stores, linker state, component resources, async tasks, or host-managed values living longer than intended. When embedding Wasmtime for WIT-defined plugins, this becomes especially visible because every instance tends to allocate its own runtime state, canonical ABI data, and host resource bindings.
Table of Contents
Understanding the Root Cause
With Wasmtime components, each plugin instance is typically backed by several layers of state:
- A Component or compiled artifact
- A Linker with host functions and resource bindings
- A Store<T> containing instance-local runtime state
- An instantiated component instance
- Potentially resources generated by WIT bindings
- Optional async futures, channels, or task handles owned by the host
The issue appears when many instances run concurrently and one or more of these objects are retained after the logical plugin lifecycle ends.
Common technical causes include:
1. Long-lived Store objects
A Store owns most runtime allocations associated with an instance. If your application creates one store per plugin instance but keeps stores in a global collection, task-local cache, or async state machine after completion, the memory remains reachable and will not be reclaimed.
2. Host resources are not explicitly dropped
WIT-based components often use resource types. If the host side stores these in maps, arenas, or reference-counted containers and never removes them when the component instance completes, memory growth looks exactly like a leak.
3. Per-instance linker or engine misuse
Engine and compiled Component objects are designed to be reused. Recreating them per request or per plugin instance can cause massive memory churn and increased retention. This is not always a leak, but under load it looks like one.
4. Async tasks keep instances alive
If a plugin invocation spawns a future holding Store, instance handles, or host state inside an Arc, dropping the top-level instance is not enough. The background task still owns the state.
5. Canonical ABI and guest memory appear to leak
Component calls move data through the canonical ABI. Large strings, lists, or resource tables can accumulate if guest values are held by host callbacks, logging layers, or buffering systems. The leak is often in the host integration rather than in Wasmtime itself.
6. Cycles in host-managed reference graphs
If your host data model uses Arc<Mutex<…>>, callback registries, or subscription lists, a cycle can prevent cleanup even when the Wasmtime instance is dropped.
The short version: Wasmtime instance memory is usually released when the Store and every object that indirectly owns it are dropped. If memory keeps climbing, some owner is still alive.
Step-by-Step Solution
The most reliable fix is to make instance lifetime explicit, reuse only the right objects, and aggressively scope per-instance state.
Step 1: Reuse Engine and compiled Component
Create the Engine once, compile the Component once, and reuse both across instances.
use wasmtime::Engine;
use wasmtime::component::Component;
pub struct PluginRuntime {
engine: Engine,
component: Component,
}
impl PluginRuntime {
pub fn new(wasm_bytes: &[u8]) -> anyhow::Result<Self> {
let engine = Engine::default();
let component = Component::from_binary(&engine, wasm_bytes)?;
Ok(Self { engine, component })
}
}
Do not compile the component separately for every parallel invocation unless you have a very specific reason.
Step 2: Create one Store per live instance and keep it tightly scoped
The Store should exist only for as long as that plugin instance is active.
use wasmtime::Store;
pub struct HostState {
resources: ResourceTable,
// other instance-local state
}
pub fn run_plugin(runtime: &PluginRuntime) -> anyhow::Result<()> {
let mut store = Store::new(
&runtime.engine,
HostState {
resources: ResourceTable::new(),
},
);
// instantiate, call exports, finish work
Ok(())
} // store drops here
If possible, avoid storing the Store inside shared containers, actor registries, or long-lived futures.
Step 3: Make host resource cleanup deterministic
If WIT resources map to host-side objects, remove them when the instance finishes. A common pattern is to wrap instance-local resources in a dedicated table that is dropped with the store.
use std::collections::HashMap;
pub struct ResourceTable {
next_id: u32,
files: HashMap<u32, Vec<u8>>,
}
impl ResourceTable {
pub fn new() -> Self {
Self {
next_id: 0,
files: HashMap::new(),
}
}
pub fn insert(&mut self, data: Vec<u8>) -> u32 {
let id = self.next_id;
self.next_id += 1;
self.files.insert(id, data);
id
}
pub fn remove(&mut self, id: u32) {
self.files.remove(&id);
}
}
impl Drop for ResourceTable {
fn drop(&mut self) {
self.files.clear();
}
}
The key idea is that resource ownership must be instance-local. If resources are stored globally, parallel instances can never free memory independently.
Step 4: Reuse the Linker when possible, but avoid capturing per-instance state in it
A Linker can often be shared, but host functions registered on it must not accidentally capture instance-specific allocations.
use wasmtime::component::Linker;
pub struct SharedRuntime {
engine: Engine,
component: Component,
linker: Linker<HostState>,
}
impl SharedRuntime {
pub fn new(wasm_bytes: &[u8]) -> anyhow::Result<Self> {
let engine = Engine::default();
let component = Component::from_binary(&engine, wasm_bytes)?;
let mut linker = Linker::new(&engine);
// Register host interfaces here.
// Make sure closures do not capture large per-instance state.
Ok(Self {
engine,
component,
linker,
})
}
}
If your linker setup closes over Arc-wrapped plugin state, that state may outlive the store and create retention that looks like a leak.
Step 5: Ensure async tasks do not outlive the instance unintentionally
If each plugin instance runs on its own async task, confirm that the task fully completes and drops everything it owns. Be especially careful with:
- tokio::spawn handles never awaited or aborted
- Channels whose receivers keep a task alive
- Streams holding store-backed values
- Timeout wrappers that retain futures after cancellation
pub async fn run_plugin_async(runtime: std::sync::Arc<PluginRuntime>) -> anyhow::Result<()> {
{
let mut store = Store::new(
&runtime.engine,
HostState {
resources: ResourceTable::new(),
},
);
// instantiate and call the component here
} // explicit scope ensures store drops before function returns
Ok(())
}
Using an explicit scope is a simple way to verify when the Store actually gets dropped.
Step 6: Drop instance handles before storing results elsewhere
Do not return or cache values that indirectly reference store-owned state. Convert outputs into plain Rust-owned values before leaving the instance scope.
pub fn invoke_and_extract(runtime: &PluginRuntime) -> anyhow::Result<String> {
let result = {
let mut store = Store::new(
&runtime.engine,
HostState {
resources: ResourceTable::new(),
},
);
// call component export
// convert guest result into owned String
String::from("owned result")
};
Ok(result)
}
This avoids accidental lifetime extension through wrappers or borrowed representations.
Step 7: Add instrumentation to prove what is retained
Before assuming a Wasmtime bug, measure which objects remain alive. Useful tactics include:
- Implement Drop on host-side state and log destruction
- Track the number of live stores, resources, and tasks
- Use a heap profiler such as pprof-rs or platform-native memory tooling
- Compare memory after forced workload completion and quiescence
impl Drop for HostState {
fn drop(&mut self) {
eprintln!("HostState dropped");
}
}
If Drop never fires, the problem is ownership. If it does fire but RSS remains high, the allocator may be retaining pages for reuse rather than leaking.
Step 8: Distinguish true leaks from allocator behavior
Rust programs often show stable high RSS after load because the allocator keeps memory mapped for future allocations. That is not the same as an unbounded leak. What matters is whether memory usage:
- Continues increasing without leveling off
- Correlates with the number of completed instances
- Remains reachable in heap profiles
If object counts return to zero but process RSS stays elevated, investigate allocator behavior before blaming Wasmtime.
Reference architecture that avoids leaks
use std::sync::Arc;
use wasmtime::{Engine, Store};
use wasmtime::component::{Component, Linker};
pub struct ResourceTable {
entries: Vec<Vec<u8>>,
}
impl ResourceTable {
pub fn new() -> Self {
Self { entries: Vec::new() }
}
}
pub struct HostState {
resources: ResourceTable,
}
pub struct Runtime {
engine: Engine,
component: Component,
linker: Linker<HostState>,
}
impl Runtime {
pub fn new(wasm: &[u8]) -> anyhow::Result<Self> {
let engine = Engine::default();
let component = Component::from_binary(&engine, wasm)?;
let linker = Linker::new(&engine);
Ok(Self { engine, component, linker })
}
pub fn run_one(&self) -> anyhow::Result<()> {
let mut store = Store::new(
&self.engine,
HostState { resources: ResourceTable::new() },
);
// let instance = bindings::Plugin::instantiate(&mut store, &self.component, &self.linker)?;
// instance.call_run(&mut store)?;
Ok(())
}
}
pub async fn run_many(runtime: Arc<Runtime>, count: usize) -> anyhow::Result<()> {
let mut tasks = Vec::new();
for _ in 0..count {
let rt = runtime.clone();
tasks.push(tokio::spawn(async move {
rt.run_one()
}));
}
for task in tasks {
task.await??;
}
Ok(())
}
This structure works because Engine, Component, and optionally Linker are shared, while Store and HostState are per-instance and naturally freed after each invocation.
Common Edge Cases
Guest code intentionally retains large in-memory state
If plugin instances are long-lived, memory growth may simply reflect legitimate guest allocations. In that case, the fix is not dropping stores earlier, but enforcing plugin lifecycle limits or recycling instances.
Pooling instances without resetting host state
If you implement your own instance pool, stale resource tables, buffers, or output caches can accumulate across reuses. Pooling only works safely when every per-instance structure is reset.
Global caches keyed by instance ID
Many hosts keep per-plugin metrics, traces, or handles in HashMap structures. If completed instance IDs are never removed, memory grows forever even though Wasmtime is behaving correctly.
Reference-counted cycles
An Arc from host state to callback registry and another Arc back to the host state prevents cleanup. Use Weak where ownership should not be strong.
Leaked JoinHandle values
If background tasks are created for plugin I/O or event handling and their JoinHandle values are forgotten while the tasks remain blocked, each task may keep the instance alive.
Misleading RSS after load tests
Even after all stores are dropped, process memory may not immediately shrink. Verify live allocation counts and object reachability rather than relying only on top-level OS memory graphs.
Per-call recompilation
Compiling the same component repeatedly can dominate memory and CPU. Reuse compiled artifacts whenever possible.
FAQ
How do I know whether this is a real Wasmtime leak or just my host integration?
Instrument Drop for your store data, resource tables, and async task wrappers. If those objects do not get dropped after work completes, the retention is in your ownership model. If they do drop, use a heap profiler to inspect what still remains live.
Should I share one Store across multiple parallel plugin instances?
Usually, no. A Store is the wrong level of sharing for parallel isolation. Prefer one store per live plugin instance, while sharing the Engine, compiled Component, and sometimes the Linker.
Can WIT resources cause leaks even if the Wasm instance is dropped?
Yes. If host-side resource backing objects are kept in global maps, arenas, or reference-counted structures, they can outlive the component instance. The WIT layer does not automatically clean up arbitrary host-owned state unless your integration makes that ownership instance-local.
The most effective fix is simple: share compiled runtime artifacts, isolate per-instance state inside the store, and guarantee deterministic cleanup of every host-managed resource. Once those lifetimes are correct, parallel Wasmtime component instances should stop exhibiting unbounded memory growth.