Limiting Plugin CPU and Memory Use
This guide answers one task: give a plugin a budget for time, memory and host calls, enforce it, and turn an exceeded budget into a clear error rather than a hung request or an exhausted machine.
Prerequisites
- [ ] A plugin host, hand-rolled or framework-based.
- [ ] Wasmtime 24+ if you are enforcing limits on a server.
- [ ] A worker in the browser, since that is the only interruption mechanism available there.
- [ ] Fixture modules that loop forever and allocate forever.
Four budgets, four mechanisms
Resource control is not one setting. Four distinct things can run away, each with its own mechanism, and a plugin system that bounds three of them has an outage waiting on the fourth.
Execution time is bounded by pre-emption: terminating a worker in a browser, or fuel and epoch
interruption in a server runtime. Memory is bounded at instantiation by a maximum on the Memory
object or a store limiter. Host calls are bounded by the host counting them and refusing to serve
past a budget. Accumulated output — logs, results, emitted events — is bounded by the host capping
what it keeps.
Only the first two are enforced by the runtime. The other two are yours, and they are the ones most often forgotten, because a plugin that exhausts them harms the host rather than itself.
Fuel: a deterministic work budget
Wasmtime’s fuel metering charges each instruction against a budget. When it runs out, execution traps with an identifiable error. Because the cost is instruction-based rather than time-based, the same input consumes the same fuel on every machine — which makes it suitable for anything that must behave identically across nodes.
let mut config = Config::new();
config.consume_fuel(true);
let engine = Engine::new(&config)?;
let mut store = Store::new(&engine, HostState::default());
store.set_fuel(20_000_000)?; // tuned from measurement, not guessed
let run = instance.get_typed_func::<(i32, i32), i64>(&mut store, "run")?;
match run.call(&mut store, (ptr, len)) {
Ok(packed) => handle(packed),
Err(e) if e.downcast_ref::<Trap>() == Some(&Trap::OutOfFuel) => reject("work budget exceeded"),
Err(e) => fault(e),
}
let used = 20_000_000 - store.get_fuel()?; // report it; tune from real distributions
Choosing the number is empirical: run your fixtures, observe the fuel used by legitimate plugins, and set the budget several times the observed p99. Reporting the consumption per invocation is what makes later tuning possible, and it also identifies which plugins are expensive long before they hit the ceiling.
Fuel costs throughput — typically 5–15% — because every block is instrumented. That is the price of determinism, and for a request path where wall-clock behaviour is what matters, epochs are cheaper.
Epochs: a cheap wall-clock deadline
Epoch interruption checks a counter at block boundaries and traps when the store’s deadline has passed. A background thread increments the counter on a timer, so the overhead is a comparison rather than bookkeeping on every instruction.
config.epoch_interruption(true);
let engine = Engine::new(&config)?;
// a ticker thread owns the clock
let e = engine.clone();
std::thread::spawn(move || loop {
std::thread::sleep(std::time::Duration::from_millis(10));
e.increment_epoch();
});
let mut store = Store::new(&engine, HostState::default());
store.set_epoch_deadline(150); // 150 ticks × 10 ms = 1.5 s
The granularity is the tick interval, so a 10 ms ticker gives you deadlines accurate to about 10 ms — far better than needed for a plugin budget and far cheaper than fuel. Use epochs for the timeout and fuel only where you also need determinism.
Memory: bound it at instantiation
Whichever runtime you use, the memory limit must be in place before the module runs, because a module’s
start section executes at instantiation and can allocate.
struct Limits { max_bytes: usize }
impl ResourceLimiter for Limits {
fn memory_growing(&mut self, _cur: usize, desired: usize, _max: Option<usize>) -> Result<bool> {
Ok(desired <= self.max_bytes)
}
fn table_growing(&mut self, _cur: u32, desired: u32, _max: Option<u32>) -> Result<bool> {
Ok(desired <= 10_000)
}
}
store.limiter(|s| &mut s.limits);
In a browser the equivalent is supplying the Memory yourself with a maximum, which requires the
plugin interface to import memory rather than export it. Bounding the table matters too: a module that
grows its function table without limit consumes host memory just as effectively as one that grows its
linear memory.
Budgets the host must enforce itself
Counting host calls is a few lines and closes a hole nothing else covers. A plugin calling log in a
tight loop cannot exhaust its own memory — it exhausts yours.
function hostFns(state) {
return {
log: (ptr, len) => {
if (++state.calls > state.maxCalls) throw new Error('host call budget exceeded');
if (state.logBytes >= state.maxLogBytes) return; // silently stop keeping
const s = readUtf8(state.memory, ptr, Math.min(len, 4096));
state.logBytes += s.length;
state.sink.push(s);
},
};
}
Throwing from a host function traps the plugin, which is the correct outcome for a budget breach — it stops immediately and the host learns why. Note the distinction between the two limits: the call budget traps, while the log accumulation limit silently stops storing, because truncating output is a less drastic response than killing an otherwise well-behaved plugin.
Report limits as errors people can act on
A limit breach should produce a message that tells the plugin author what to change and the operator what happened. “Error” tells nobody anything.
plugin acme-formatter 1.4.0
rejected: work budget exceeded (20,000,000 fuel)
typical usage for this plugin: p50 1.2M, p99 4.8M
suggestion: the input was 40× larger than usual; consider batching
Publishing the typical distribution alongside the breach converts a mysterious failure into an actionable one. It also tells you when a limit is wrong: if legitimate plugins regularly consume 80% of the budget, the budget is too tight and the next unusual input will trip it.
Expected output
Instrumented limits produce a per-invocation record worth keeping:
plugin formatter abi 3
fuel used 1,204,881 / 20,000,000
epoch 0.31 s / 1.50 s deadline
memory peak 38 / 256 pages
host calls 12 / 500
output 8.2 kB / 1 MB cap
verdict ok
Track the ratios rather than the absolutes. A plugin that has crept from 5% to 60% of its fuel budget over three releases is heading for a failure, and the graph shows it long before a user does.
Gotchas
- Limits set after instantiation. A
startsection runs first. Configure the store and memory before instantiating. - Fuel budget guessed. Measure legitimate plugins and set several times the p99.
- Epoch ticker not started. The deadline never arrives and the limit silently does nothing. Test it with a looping fixture.
- No table limit. Growing a function table consumes host memory too.
- Host call budget missing. The one runaway the runtime cannot see.
- Limits per invocation but not per tenant. A caller that invokes a plugin ten thousand times has bypassed every per-call budget you set.
Performance note
Measured on a server workload: epoch interruption added under 1% to throughput and fuel metering added 11%. A memory limiter costs nothing measurable, since it is consulted only on growth. Host call counting adds a single increment per call, which is irrelevant next to the marshalling around it. The only limit with a real price is fuel, and it buys determinism that nothing else provides.
Frequently Asked Questions
Can I limit a plugin in the browser without a worker? No. There is no way to interrupt a running instance on the same thread, so a deadline requires something you can terminate. Memory limits work anywhere, but time limits require the worker.
What is a reasonable default timeout? Start from what your own code would take for the same job and allow an order of magnitude. Most plugin invocations are tens of milliseconds, so budgets between one and two seconds catch runaways without tripping legitimate work.
Should a plugin that hits a limit be disabled? After repeated breaches, yes — a plugin that fails every invocation is a permanent cost with no benefit. Disable it automatically after a few consecutive faults, tell the user which plugin and why, and let them re-enable it once the author ships a fix.
How do I limit a plugin that spends its time inside my host functions? Charge for them. A host function that performs real work — a lookup, a transformation, a fetch from an allowlist — should consume from the same budget as the plugin’s own execution, either by deducting fuel explicitly or by counting against a separate host-work allowance. Otherwise a plugin can do unbounded work while appearing to consume almost none of its own.
Do limits interact with threads? They do, and not always intuitively. A threaded plugin consumes fuel on every thread against separate stores, so a single budget no longer describes the whole invocation. For plugins, single-threaded execution is the simpler and usually correct choice; reserve threading for trusted first-party modules where the accounting is yours to manage.
Related
- Loading untrusted plugins safely — the checks before these limits apply.
- Sandboxing untrusted code with Wasm — containment in the round.
- Cold start characteristics of server-side Wasm — the cost of the instances these limits apply to.
← Back to Plugin Systems & Extensibility