Building a Plugin Host with Extism

This guide answers one task: stand up a working WebAssembly plugin system using Extism rather than hand-writing the allocator handshake, the limits and the language bindings — and understand what that choice costs.

Prerequisites

  • [ ] Node 18+ or a browser build, plus @extism/extism.
  • [ ] A plugin development kit for whichever language plugin authors will use.
  • [ ] A clear idea of your host functions; the framework does not choose them for you.
  • [ ] A plugin to test with, even a trivial one.

What the framework actually provides

Extism sits between a host and a .wasm module and supplies the parts every plugin system needs and nobody enjoys writing: a memory protocol for passing arbitrary-length inputs and outputs, a consistent way to declare and call host functions, resource limits, and development kits that make the plugin side a few lines in each supported language.

What it does not provide is your interface. The functions you expose, the payload format, the version policy and the validation of results are still design decisions — the framework removes the plumbing, not the thinking. That is the right division, and it is worth being clear about it before adopting one, because a framework that appeared to answer the interface question would be answering it badly.

What you keep owning The framework supplies the memory protocol, host function plumbing and resource limits. The interface design, payload format, versioning and result validation remain the application's responsibility. the framework handles input and output memory protocol host function registration timeouts and memory caps plugin kits for several languages you still own which capabilities to expose payload format and schema version policy validating what comes back Adopting a framework is a decision about plumbing. The parts that determine whether a plugin ecosystem works are on the right-hand side.

A host in a dozen lines

The host side loads a module, sets limits, and calls a named export with a byte payload.

import createPlugin from '@extism/extism';

const plugin = await createPlugin('./plugins/formatter.wasm', {
  useWasi: false,                       // no filesystem, no clock, no environment
  allowedHosts: [],                     // no outbound network
  config: { locale: 'en-GB' },          // string config the plugin can read
  runInWorker: true,                    // browser: keeps the main thread free
  timeoutMs: 2000,
  memory: { maxPages: 256 },
});

const out = await plugin.call('format', JSON.stringify({ text: 'hello' }));
const result = JSON.parse(out.text());
await plugin.close();

useWasi: false is the important default to set deliberately. With WASI enabled the plugin gets a filesystem abstraction, environment variables and a clock, which is convenient and is exactly the ambient authority a plugin sandbox exists to withhold. Turn it on only when a specific plugin needs it and you have decided what it may see.

Host functions, which are still capabilities

Extism lets the host expose functions the plugin can import. The framework handles the marshalling; the decision about what to expose is unchanged from a hand-rolled design, and the same discipline applies.

const plugin = await createPlugin(wasmUrl, {
  functions: {
    'extism:host/user': {
      lookup_price(currentPlugin, offset) {
        const sku = currentPlugin.read(offset).text();
        const price = catalogue.get(sku);              // host owns the data
        if (price === undefined) return currentPlugin.store('');
        return currentPlugin.store(String(price));
      },
    },
  },
});

Notice that the function is narrow: it looks up a price by identifier from a catalogue the host holds. A more general query_database(sql) would be easier to write and would hand the plugin your database. Frameworks make it easy to expose either; the judgement is yours.

The plugin side in three languages

The development kits are what make an ecosystem practical, because a plugin author does not have to understand the memory protocol at all.

// Rust
use extism_pdk::*;

#[plugin_fn]
pub fn format(input: String) -> FnResult<String> {
    let cfg = config::get("locale")?.unwrap_or_else(|| "en".into());
    Ok(format!("[{cfg}] {}", input.trim()))
}
// Go (TinyGo)
//export format
func format() int32 {
    input := pdk.InputString()
    pdk.OutputString("[go] " + strings.TrimSpace(input))
    return 0
}
// JavaScript, compiled with the js-pdk
export function format() {
  const input = Host.inputString();
  Host.outputString('[js] ' + input.trim());
}

Three languages, the same interface, no hand-written allocator on any of them. Publishing a template per language remains worth doing — the kits remove the protocol, not the project setup.

Limits and what they actually enforce

The limits available depend on the runtime underneath. In a browser, the timeout is implemented by terminating a worker, so it is reliable but coarse: the plugin is gone and there is no partial result. On a server backed by Wasmtime, timeouts use epoch interruption and can be complemented by fuel metering for deterministic budgets.

const plugin = await createPlugin(wasmUrl, {
  timeoutMs: 1500,
  memory: { maxPages: 128, maxHttpResponseBytes: 0, maxVarBytes: 65536 },
  allowedPaths: {},          // nothing mounted
  allowedHosts: [],          // nothing reachable
});

Set every one of these explicitly, including the ones whose defaults are already what you want. An explicit zero is a statement a reviewer can check; an omitted option is a question nobody answered.

Each option exists because of a specific failure Timeouts bound runaway execution, page limits bound memory, an empty host list prevents network access, and variable limits bound what the plugin can accumulate between calls. timeoutMs stops an endless loop worker terminated maxPages stops a memory bomb grow fails cleanly allowedHosts no exfiltration empty by default maxVarBytes bounds stored state host memory protected Write them all out even where the default is correct — a configuration review can only check what it can see.

Managing plugin lifetime in a server

In a browser, a plugin usually lives for one user action. On a server handling many requests, the lifetime question becomes a real design decision with a cost attached to each answer.

Creating a plugin per request is the safest and costs compilation on every one, which for a 61 kB module is 8–15 ms — acceptable for an infrequent operation, far too much for a hot path. Caching the compiled artifact and creating only a fresh instance per request brings that down to well under a millisecond while keeping the isolation, and is the arrangement to reach for by default.

Keeping a long-lived instance per plugin is fastest and reintroduces state between requests: whatever the previous caller left in linear memory is visible to the next. That is acceptable only when all callers belong to the same tenant and the plugin is trusted to be stateless, and it should be a deliberate, documented exception rather than the default.

const cache = new Map();                      // plugin id → compiled artifact
async function forRequest(id) {
  if (!cache.has(id)) cache.set(id, await compilePlugin(id));
  return instantiate(cache.get(id), perRequestOptions);   // fresh memory every time
}

Whichever you choose, close instances explicitly. A server that creates plugins and never closes them leaks memory at a rate proportional to traffic, and the symptom — a slow climb in resident size over hours — is easy to attribute to everything except the plugin host.

Expected output

A working host prints the plugin’s response and the resources it used:

plugin  formatter.wasm  (61 kB)
config  locale=en-GB
call    format  → 4.2 ms
result  {"text":"[en-GB] hello"}  (schema ok)
limits  timeout 2000 ms, maxPages 256, hosts []

A timeout produces a clean rejection rather than a hang, which is the behaviour to verify first with a deliberately looping fixture:

call    format  → rejected after 1500 ms (timeout)
plugin  disabled after 3 consecutive faults

What you give up

Three things, all worth weighing.

A dependency, with its own release cadence and its own bugs, sitting in the security-critical path of your application. That is an ordinary trade, but it is a real one for a component whose whole purpose is containment.

Some control over the memory protocol. The framework’s input and output convention is fixed, which is fine until you want something it does not express — streaming, for instance, or passing a large buffer without a copy.

And a layer between you and the runtime’s own knobs. Fuel metering, custom resource limiters and runtime-specific features are reachable only as far as the framework exposes them.

Against that: a working plugin system in an afternoon, plugin kits for languages you would otherwise have to support yourself, and a protocol that has been debugged by other people. For most applications that trade is clearly worth making, and hand-rolling makes sense mainly when the interface has requirements the framework’s protocol cannot express.

What the framework saves you The parts a framework supplies are the parts that are tedious and easy to get subtly wrong: marshalling, host functions, limits and a consistent error surface. hand-rolled host marshalling, memory, limits, errors and a loader, all your own framework host your host functions everything else is provided and tested The trade is flexibility: a framework fixes the calling convention, which is usually a feature. A hand-rolled host is the right answer when the interface is narrow and the dependency is not wanted.

Gotchas

  • WASI left enabled. The plugin gets a filesystem and clock you did not intend to grant.
  • allowedHosts with a wildcard. Grants outbound network to every plugin, permanently.
  • Not closing plugins. Instances hold memory; close them when done, especially in a long-lived server process.
  • Assuming the framework validates output. It does not know your schema. Validate every result.
  • Relying on host function ordering. Calls arrive as the plugin makes them; do not assume a sequence.
  • Different behaviour between the browser and server runtimes. Test both if you ship both; the limits are enforced by different mechanisms.

Performance note

Loading a 61 kB plugin takes 8–15 ms including compilation, and a call with a small JSON payload costs roughly 0.2–0.5 ms of overhead on top of the plugin’s own work — the memory protocol plus the JSON parse on each side. That overhead is irrelevant for plugins doing real work and noticeable for very frequent tiny calls, where batching several items into one call recovers most of it.

Frequently Asked Questions

Should I use a framework or hand-roll the interface? Use a framework unless you have a specific requirement it cannot meet. The plumbing is not where a plugin system’s value lies, and the language kits are worth a great deal on their own.

Does it work in the browser? Yes, with the browser build, and running plugins in a worker is supported directly — which matters, because the timeout depends on being able to terminate something.

Can I migrate from a hand-rolled interface later? In both directions, with effort. The plugin-facing protocol changes, so existing plugins need recompiling — which is precisely the kind of change the ABI version export exists to make survivable.

← Back to Plugin Systems & Extensibility