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.Moduleyou 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.
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.
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.
Gotchas
- Catching without checking the type. A
TypeErrorfrom 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.Moduleand discard only the instance. - Forwarding the error object through
postMessage. Loses the type; classify in the worker. - No panic hook. The message is
unreachable executedand 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.
Related
- Debugging unreachable executed traps — finding the cause.
- Returning error codes without exceptions — avoiding the trap in the first place.
- Handling panics in Rust Wasm — the module side.
← Back to Errors & Traps Across the Boundary