Catching Wasm Traps in JavaScript

This guide answers one task: write the host-side code that catches a WebAssembly trap, distinguishes it from every other kind of error, and leaves the application able to continue.

Prerequisites

  • [ ] A module whose calls can fail — which is all of them.
  • [ ] A compiled WebAssembly.Module you can instantiate again cheaply.
  • [ ] A panic hook in the module, so the trap carries a message.
  • [ ] Somewhere to report, because a trap is always a bug.

A trap is a RuntimeError

When a module traps, the call throws a WebAssembly.RuntimeError. That type is the thing to branch on, because everything else that can go wrong throws something different.

try {
  const n = instance.exports.process(ptr, len);
  return { ok: true, value: n };
} catch (e) {
  if (e instanceof WebAssembly.RuntimeError) return { ok: false, kind: 'trap', error: e };
  if (e instanceof RangeError)               return { ok: false, kind: 'memory', error: e };
  if (e instanceof WebAssembly.LinkError)    return { ok: false, kind: 'link', error: e };
  return { ok: false, kind: 'host', error: e };
}

Four types, four meanings. A RuntimeError is a trap inside the module. A RangeError is usually a detached or oversized typed array on the host side. A LinkError is a missing or mismatched import, which happens at instantiation rather than at call time. Anything else is a bug in your own JavaScript.

Four types, four responses A RuntimeError means a trap and the instance should be discarded. A RangeError points at host-side memory handling. A LinkError means imports do not match. Anything else is a bug in the host code. RuntimeError a trap in the module memory now suspect discard the instance RangeError detached or oversized view host-side mistake rebuild the view LinkError imports do not match at instantiation fix the import object anything else your own JavaScript nothing to do with the module ordinary debugging A catch block that does not branch on the type sends all four to the same handler, which can only be right for one of them.

Why the instance must go

A trap stops execution partway through an instruction. Whatever the interrupted function was doing is left half done: an allocator’s free list may be inconsistent, a length field written without its data, a reference count incremented without its object.

The module has no mechanism to clean that up — there is no unwinding, no destructor, no handler. So an instance that has trapped should be treated the way you would treat a process that crashed: not resumed.

Recreating it is cheap because the expensive step is compilation, which you keep.

let compiled = null;                             // WebAssembly.Module, compiled once
let instance = null;

async function getInstance() {
  compiled ??= await WebAssembly.compileStreaming(fetch(WASM_URL));
  instance ??= await WebAssembly.instantiate(compiled, imports);
  return instance;
}

function discardInstance() { instance = null; }   // the module stays compiled

Instantiating from an already-compiled module takes well under a millisecond, so discarding on every trap costs nothing measurable and removes a whole category of follow-on failure.

A wrapper that does it once

Rather than repeating the pattern at every call site, wrap the module’s exports so the handling happens in one place.

export function makeClient(compiled, imports) {
  let inst = null;
  const ensure = async () => (inst ??= (await WebAssembly.instantiate(compiled, imports)).instance);

  return async function call(name, ...args) {
    const i = await ensure();
    try {
      return i.exports[name](...args);
    } catch (e) {
      if (e instanceof WebAssembly.RuntimeError) {
        inst = null;                                        // next call gets a fresh instance
        report({ kind: 'wasm-trap', fn: name, message: String(e.message), build: BUILD_HASH });
        throw new WasmFaultError(name, e);
      }
      throw e;
    }
  };
}
const call = makeClient(compiled, imports);
const n = await call('process', ptr, len);

Every call site now gets correct trap handling without thinking about it, and the reporting is consistent enough to aggregate — which is what turns a scattering of individual failures into a chart showing which export traps most often.

Where the catch belongs

A try around every export call is noisy and, worse, encourages catching at a level that cannot do anything useful. Two placements work.

At the client wrapper, as above, where the handler knows how to discard and recreate. That is the right place for the mechanical part of the response, and it should rethrow so that callers still see a failure.

At the operation boundary — the click handler, the request handler, the queue consumer — where the code knows what the user was trying to do and can say something sensible about it. That is the right place for the product part of the response.

async function onConvertClick(file) {
  try {
    setStatus('converting');
    const out = await call('convert', await intoWasm(file));
    setStatus('done'); offerDownload(out);
  } catch (e) {
    if (e instanceof WasmFaultError) setStatus('failed', 'Something went wrong converting that file.');
    else setStatus('failed', 'That file could not be converted.');
  }
}

What does not work is catching in the middle — inside a helper that neither owns the instance nor knows the operation — because such a handler can only log and rethrow, which the outer handler would have done anyway.

Across a worker boundary

When the module runs in a worker, the error has to be classified where the type information exists, because an Error sent through postMessage arrives as a plain object.

// worker.js
try {
  self.postMessage({ id, ok: true, value: run(input) });
} catch (e) {
  self.postMessage({
    id, ok: false,
    kind: e instanceof WebAssembly.RuntimeError ? 'trap' : 'host',
    message: String(e && e.message || e),
  });
}
// main thread
if (!msg.ok) {
  if (msg.kind === 'trap') { await recycleWorker(); throw new WasmFaultError(msg.message); }
  throw new Error(msg.message);
}

Recycling the worker replaces discarding the instance, and is simpler: terminate it, create a new one, and the fresh worker instantiates a fresh module. The cost is a few milliseconds on a path that has already failed.

Classify where the type survives The worker catches the error while its type is still known, tags the message, and posts it. The main thread reads the tag, recycles the worker on a trap, and surfaces a typed failure to the caller. worker instanceof still works here tags the message {kind} main thread reads the tag recycles on a trap fresh worker new instance, clean memory next call succeeds Forwarding the raw error instead loses its type, and the main thread cannot tell a trap from a typo in the worker's own code.

Timeouts, which are not catchable

One failure mode deserves separate mention because no catch block helps with it: a module that does not return.

An infinite loop inside WebAssembly holds the thread. There is no timeout, no interrupt and no way for the host to regain control — on the main thread that is a frozen tab, and no error is ever thrown.

The only browser answer is to run the module in a worker and terminate it on a deadline.

function callWithDeadline(worker, payload, ms) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => { worker.terminate(); reject(new Error('wasm timeout')); }, ms);
    worker.onmessage = ({ data }) => { clearTimeout(timer); resolve(data); };
    worker.onerror = (e) => { clearTimeout(timer); reject(e); };
    worker.postMessage(payload);
  });
}

A terminated worker is gone entirely, so the recovery is to create a new one — the same recycling as after a trap, for a different cause. Set the deadline from the work’s realistic worst case rather than its typical one, and log every timeout, because a rising rate usually means an input distribution changed rather than that the deadline was wrong.

On a server runtime the equivalent is fuel metering or epoch interruption, which pre-empt the module cleanly and report why, as described in limiting plugin CPU and memory use.

Expected output

With a panic hook installed, the message and the type together say what happened:

panicked at 'index out of bounds: the len is 32 but the index is 41', src/parse.rs:87:13
caught: RuntimeError: unreachable
  kind: trap, fn: process, build: a91c3f
  instance discarded; next call will reinstantiate
# and the next call, on a fresh instance
process(validPtr, 128) → 128

That second line is the assertion worth having in a test: after a trap, the next ordinary call succeeds. A module that stays broken for the rest of the session is the outcome this whole pattern exists to prevent.

Where a trap becomes an exception The engine turns a trap into a JavaScript RuntimeError at the boundary. The Wasm frames are gone by then, which is why the stack looks empty unless names survived in the binary. trapping instruction bounds check fails call aborts frames unwound boundary becomes RuntimeError catch block the caller decides A caught trap does not mean the instance is usable: memory may be half-written and invariants broken. The safe response to an unexpected trap is to discard the instance and build a fresh one. Keep the name section in at least one build, or every trace reads wasm-function[214] and tells you nothing.

Gotchas

  • Catching without checking the type. A TypeError from your own code is handled as if the module trapped.
  • Reusing the trapped instance. Silent wrong answers, or a second trap somewhere confusing.
  • Recompiling instead of reinstantiating. Wasteful; keep the WebAssembly.Module and discard only the instance.
  • Forwarding the error object through postMessage. Loses the type; classify in the worker.
  • No panic hook. The message is unreachable executed and says nothing.
  • Swallowing the trap. A trap is a bug; report it even when the user-facing recovery is seamless.

Performance note

Reinstantiating from a cached WebAssembly.Module took 0.4 ms for a 96 kB module, against 62 ms to compile it again — which is why keeping the compiled module and discarding only the instance is the right split. Recycling a worker cost 4–9 ms including its module instantiation, still negligible on a path that has already failed.

Frequently Asked Questions

Can I find out which instruction trapped? The stack trace gives a function and an offset, which with the name section names the function and with DWARF gives a line — see reading Wasm stack traces.

What if the trap happens during instantiation? Then the start function trapped and there is no instance at all. Handle that separately from a call failure — the response is usually to fall back rather than to retry, since the module will fail the same way next time.

Is it safe to keep using other instances of the same module? Yes. Instances are independent, each with its own memory. Only the one that trapped is suspect.

Should a trap be retried? Only with different input. Retrying the same call on a fresh instance will usually trap again, and a retry loop over a deterministic failure is an outage rather than a recovery.

← Back to Errors & Traps Across the Boundary