Reinstantiating a Module to Reset State

This page answers one task: a WebAssembly module has accumulated state you want gone — after a trap, between unrelated jobs, between test cases, or because a library leaks memory over time — and the most reliable way to clear it is to start over with a new instance. You want to reset correctly, keep the cost low, and avoid leaking the old instance.

Prerequisites

  • [ ] A compiled WebAssembly.Module (from compileStreaming) or the module bytes.
  • [ ] Knowledge of which state lives inside the instance and which lives in JavaScript.
  • [ ] A way to measure instantiation time (performance.now()).

Why a new instance is a clean slate

Everything mutable that a module owns belongs to its instance: linear memory (unless imported), globals, tables (unless imported), and the state of passive data and element segments. Creating a new instance from the same compiled module creates fresh copies of all of them — memory zeroed and re-initialised from data segments, globals set to their initial values, tables refilled from element segments, the start function run again. No code inside the module can carry state from the old instance to the new one, so the reset is complete by construction. That is stronger than any reset() function a library might export, which only clears what its authors remembered.

Compilation is the expensive part, and it does not need repeating: a WebAssembly.Module is immutable and can be instantiated any number of times. Reset is therefore “instantiate again”, not “load again”.

Resetting by reinstantiation The module is compiled once and kept. Each job gets a fresh instance created from the compiled module with a fresh import object. When the job ends or traps, references to the instance are dropped so it can be garbage collected, and the next job instantiates again from the same compiled module. compile once WebAssembly.Module instantiate fresh memory + globals run job state accumulates drop references old instance → GC instantiate again clean slate

Step 1 — keep the compiled module

const module = await WebAssembly.compileStreaming(fetch("/engine.wasm"));

async function freshInstance() {
  const memory = new WebAssembly.Memory({ initial: 64, maximum: 4096 });   // only if the module imports memory
  const instance = await WebAssembly.instantiate(module, { env: { memory, log: console.log } });
  return { instance, memory };
}

Passing a Module (not bytes) to WebAssembly.instantiate returns the instance directly and skips compilation. If the module imports its memory, create a new memory every time; reusing the old one would carry state over and defeat the reset.

Step 2 — reset on demand

let current = await freshInstance();

async function reset() {
  current = await freshInstance();         // old instance becomes unreachable
}

async function runJob(input) {
  try {
    return current.instance.exports.process(input);
  } catch (e) {
    if (e instanceof WebAssembly.RuntimeError) await reset();   // traps leave state undefined
    throw e;
  }
}

After a trap, the instance’s memory may hold half-updated data structures — a heap allocator mid-update, a lock still held — so continuing to use it is unsafe. Resetting is the standard recovery.

Step 3 — release the old instance

The old instance is garbage-collected once nothing references it. Common leaks keep it alive: a closure in the import object stored somewhere long-lived, an exported function kept in a JavaScript callback registry, a typed array view of the old memory cached in a module-level variable, or an event listener installed by glue code. Clear those references in reset(). Large memories are freed only when collected, so leaking old instances quickly exhausts address space on 32-bit or memory-constrained devices.

Step 4 — carry over the state you want

Sometimes only part of the state should be reset — configuration and loaded dictionaries should survive, per-job scratch data should not. Keep durable state outside the instance (in JavaScript, or in a separate long-lived module) and re-apply it after reinstantiation: call an exported configure() with saved settings, or copy a prepared region into the new memory. If re-applying is expensive, consider snapshotting the initialised memory once and copying it into each new instance (below).

Step 5 — measure the cost

const t0 = performance.now();
for (let i = 0; i < 100; i++) await freshInstance();
console.log(((performance.now() - t0) / 100).toFixed(2), "ms per instantiation");

For a small module, instantiation from a compiled Module takes well under a millisecond. For large modules, the cost is dominated by copying data segments into memory and by running the start function and any constructors. If resets are frequent, those are what to shrink.

What a reset clears and what it does not Reinstantiation clears memory, globals and tables that the module owns and reruns the start function. It does not clear imported memory or tables, which the host must recreate, nor JavaScript state held outside the instance, nor the compiled module, which is reused. state owned by module imported outside the instance linear memory reset kept unless recreated n/a globals reset kept n/a tables reset kept unless recreated n/a JS caches and callbacks n/a n/a kept — clear manually compiled code reused reused reused

Snapshotting initialised memory

When initialisation is expensive — a start function that builds tables, constructors that parse embedded data — run it once, copy the resulting memory, and seed each new instance from the copy instead of re-running initialisation. This works when the module imports its memory: create a new WebAssembly.Memory of the same size, copy the snapshot into it with new Uint8Array(memory.buffer).set(snapshot), and instantiate with an import object whose start-time work is skipped (for example through a flag the module checks). Tools like Wizer do the same at build time, producing a module whose data segments already contain the initialised state, so every instantiation starts warm without any runtime snapshot code.

Reset strategies compared

Reinstantiation is the most thorough reset. An exported reset() function is cheaper but only as complete as its implementation. Running each job in a separate worker with its own instance isolates jobs from each other and lets you terminate a stuck job, at the cost of worker startup and message passing. Choose by how much you trust the module’s own cleanup and how expensive one instantiation is.

Resetting inside a worker

Long-running or untrusted jobs are often better reset by replacing the whole worker, not just the instance. Post the compiled Module to a worker once — modules are structured-cloneable — and let the worker instantiate it for each job. If a job hangs in an infinite loop, no JavaScript on the main thread can interrupt the Wasm call, but worker.terminate() can; the replacement worker receives the same compiled module and is ready within a few milliseconds. For jobs that only need state cleared, keep the worker and reinstantiate inside it; for jobs that may never return, terminate and respawn.

// main thread
const worker = new Worker("/runner.js", { type: "module" });
worker.postMessage({ module });            // compiled once, cloned cheaply
// runner.js
let module, instance;
onmessage = async ({ data }) => {
  if (data.module) { module = data.module; return; }
  instance = await WebAssembly.instantiate(module, imports());   // fresh per job
  postMessage(instance.exports.process(data.input));
};

Testing with a fresh instance per test

Test suites benefit from the same idea. A shared instance lets one test’s leftover state — a global flag, a half-full buffer, a leaked allocation — change the outcome of the next, which produces failures that depend on test order. Create the compiled module in a beforeAll hook and a new instance in beforeEach; the per-test cost is small and every test starts from the state the module defines. When a test needs specific starting data, write it into the fresh memory right after instantiation rather than relying on what a previous test left behind.

Detecting when a reset is needed

Not every reset has an obvious trigger. Watch for signs that state has degraded: memory.buffer.byteLength growing steadily across jobs that should use constant memory (a leak), exported counters or allocator statistics drifting, or job latency creeping up as fragmentation increases. A simple policy — reset every N jobs, or whenever memory exceeds a threshold — keeps long-lived pages and servers stable even when a third-party library leaks.

Expected output

The module is compiled once; each reset creates a fresh instance in under a millisecond for a small module; traps trigger a reset before the next job; old instances are collected because no callbacks or views keep them alive; and durable configuration is re-applied after each reset.

Gotchas

  • Recompiling on every reset. Keep the Module; instantiate from it.
  • Reusing imported memory. The old state survives. Create a new memory.
  • Leaked references to old instances. Memory is never freed. Clear callbacks and views.
  • Continuing after a trap. Internal state may be corrupt. Reset first.
  • Expensive start functions. Every reset pays them. Snapshot or pre-initialise.
  • Sharing one instance across tests. Order-dependent failures. Instantiate per test.

Performance note

For a 300 KB module with a 2 MB data section, instantiation from a compiled module took about 1.8 ms, compared with 45 ms for compiling and instantiating from bytes; pre-initialising with Wizer removed a 12 ms start function from every reset.

Cost of one reset Approximate milliseconds per reset for a medium module when compiling and instantiating from bytes, instantiating from a cached compiled module with a start function, and instantiating a pre-initialised module. ms per reset compile + instantiate from bytes 45 ms instantiate cached module + start 14 ms instantiate pre-initialised module 1.8 ms

Frequently Asked Questions

Does reinstantiation reset imported globals? No — imported globals belong to the host; recreate them if they must reset.

Can I reset memory without a new instance? Only partially — you can zero it, but globals, tables and allocator state inside the module remain.

Is the compiled code shared between instances? Yes — instances of one Module share compiled code; only state is per instance.

Does a new instance run the start function again? Yes — every instantiation runs it.

Can a hung Wasm call be interrupted? Not from the same thread; run it in a worker and terminate the worker.

How often should a leaking module be reset? Pick a job count or memory threshold from measurements, and reset when either is reached.

← Back to Wasm Instantiation Lifecycle