Hot Reloading Wasm Plugins
This page answers one task: a long-running host — a server, an editor, a desktop app — runs WebAssembly plugins, and a plugin must be upgraded or reloaded during development without restarting the host or losing the plugin’s state.
Prerequisites
- [ ] A host that loads plugins through a single place in the code (a registry), using wasmtime, Extism, Node or the browser’s WebAssembly API.
- [ ] Plugins that can export and import their state, or that are stateless.
- [ ] A source of new versions: a file watcher in development, a registry or update channel in production.
Why WebAssembly makes hot reload tractable
Hot reloading native plugins — shared libraries loaded with dlopen — is notoriously fragile: old code may still be on the stack, global state is shared
with the host, and unloading a library while anything references it crashes the process. WebAssembly plugins avoid most of that by construction. A plugin
instance is isolated: its memory, globals and tables belong to it, and the host only holds references to its exports. Replacing a plugin means creating a
new instance from a new module and dropping the old one once nothing uses it; no code is patched in place, and no host memory is shared.
What remains is the plugin’s state. A new instance starts empty: caches, open documents, counters and configuration live in the old instance’s linear memory. Hot reload therefore needs a state hand-off: the old instance serialises what matters, the new instance restores it. The rest is careful sequencing so that no call is lost or sent to a half-initialised instance.
Step 1 — route every call through a registry
The host must not hold direct references to a plugin’s exports scattered across its code; otherwise there is no single place to swap. Wrap each plugin in a small object that owns the current instance and exposes calls:
class PluginSlot {
constructor(name) { this.name = name; this.current = null; this.inflight = 0; this.paused = null; }
async call(fn, ...args) {
if (this.paused) await this.paused; // wait while a swap is in progress
const inst = this.current;
this.inflight++;
try { return await inst.call(fn, ...args); }
finally { if (--this.inflight === 0) this.onIdle?.(); }
}
}
Callers hold the slot, never the instance. The in-flight counter tells the swap when the old instance is idle.
Step 2 — compile the new version off the critical path
Compilation can take tens or hundreds of milliseconds. Do it before pausing anything:
async function prepare(bytes) {
const module = await WebAssembly.compile(bytes); // or Module::new / Plugin::new in wasmtime or Extism
if (!WebAssembly.Module.exports(module).some((e) => e.name === "transform")) throw new Error("missing export");
return module;
}
Validate the new module’s contract here — required exports present, contract version compatible — so an incompatible upload is rejected while the old version keeps serving, as discussed in versioning a Wasm plugin API.
Step 3 — drain, export state, swap
async function swap(slot, module) {
let resume;
slot.paused = new Promise((r) => (resume = r)); // new calls wait
if (slot.inflight > 0) await new Promise((r) => (slot.onIdle = r));
const state = slot.current.exports.export_state?.(); // bytes owned by the old instance
const next = await instantiate(module);
try {
if (state) next.exports.import_state(state);
await next.call("health"); // smoke test before going live
} catch (err) {
slot.paused = null; resume(); // keep the old version
throw err;
}
const old = slot.current;
slot.current = next;
slot.paused = null; resume();
old.close?.(); // release the old instance and its memory
}
Calls that arrive during the swap wait on paused and then go to the new instance; none are lost. If the new version fails to import state or fails its
health check, the old instance stays in place.
Step 4 — design the state format for change
State exported by version 1 is imported by version 2, so the format is a contract too. Use a self-describing, versioned encoding — JSON or MessagePack with a
version field — rather than a raw memory dump, and have import_state accept older versions and migrate them. Keep the state small: configuration,
user data and anything expensive to rebuild. Caches can usually be dropped and rebuilt lazily. Plugins that are stateless need no hand-off at all, which
is a good reason to keep plugin state in the host where possible and pass it in on each call.
Step 5 — watch files in development
In development, a file watcher closes the loop: rebuild the plugin on save, then swap it in.
import { watch } from "node:fs";
watch("plugins/markdown.wasm", { persistent: true }, async () => {
try { await swap(slots.markdown, await prepare(await readFile("plugins/markdown.wasm"))); console.log("reloaded"); }
catch (e) { console.error("reload failed, keeping previous version:", e.message); }
});
Debounce the watcher — compilers often write the output in several steps — and log the outcome clearly. Combined with cargo watch or the guest
language’s own watcher, a change to plugin source reaches the running host in a second or two with its state intact.
Testing the reload path
Reload logic runs rarely in production, which is exactly why it needs automated tests. Build two versions of a test plugin — v1 that stores some state,
v2 that reads it in a newer format — and write host tests that start v1, make calls that change its state, swap to v2 while a stream of concurrent calls
is running, and then assert that no call failed, every call after the swap saw v2’s behaviour, and the state survived the migration. Add a v2 whose
import_state throws and assert that v1 keeps serving and the error is reported. Add a v2 that traps in its health check. Run the suite with several
iterations and random timing so that races between the drain and incoming calls get exercised. These tests are short to write once the slot abstraction
exists, and they turn hot reload from a feature you hope works into one you know does.
Hot reload in production
In production the same mechanism supports zero-downtime upgrades, with more caution. Roll out gradually: swap the plugin for a fraction of tenants or requests first, compare error rates and latency, and continue only when they match. Keep the previous module compiled and ready, so rolling back is a swap rather than a download and compile. Record which plugin version handled each request, so issues can be traced to a release. Limit how often a plugin can be reloaded, to avoid thrashing when a broken build is pushed repeatedly. And remember that hosts serving many concurrent requests may want several instances of the same plugin — one per worker or per tenant — so the swap logic must update every slot, ideally from the same compiled module, and the old module’s memory is only freed when the last instance using it is dropped. With Extism or wasmtime on the server, compiled modules are shareable across threads, which makes swapping many instances cheap.
Expected output
Saving a change to the plugin’s source rebuilds it and swaps it into the running host within about two seconds; the plugin’s user settings survive the swap; calls made during the swap complete normally against the new version; and a deliberately broken build is rejected while the previous version keeps serving.
Gotchas
- Holding direct export references. They keep calling the old instance. Route calls through a slot.
- Swapping mid-call. In-flight calls run on the old instance; drain before exporting state.
- Raw memory dumps as state. Layouts change between versions. Use a versioned, serialised format.
- No rollback. A broken version takes the feature down. Health-check before going live.
- Old instances never freed. Forgotten references pin memory. Close the old instance after the swap.
Performance note
Swapping a 600 KB plugin took about 40 ms end to end in Node: 28 ms compiling (off the critical path), 2 ms draining, 6 ms exporting and importing 200 KB of state, and under 1 ms instantiating. Calls arriving during the swap were delayed by at most 9 ms.
Frequently Asked Questions
Can I hot-reload a plugin that holds open connections? Connections should belong to the host, exposed to the plugin through host functions; then the plugin can be swapped without dropping them.
Does the browser support this?
Yes — the same pattern works with WebAssembly.compile and instantiate in a page or worker.
What if the new version needs more memory? It is a new instance with its own memory, sized by the new module; the old memory is freed when the old instance is dropped.
How do I reload many instances at once? Compile once, then swap each slot from the same compiled module; release the old module after the last instance switches.
Should plugins know they are being reloaded? Only through the state export and import functions. Keep everything else in the host so plugin authors do not have to handle reload logic.
Related
- Building a plugin host with Extism — the host this builds on.
- Recovering a module after a trap — the same replace-the-instance idea after a crash.
- Instantiating one module many times — compiling once.
- Hot reloading a Rust Wasm crate during development — the build side of the loop.
← Back to Plugin Systems & Extensibility