Errors & Traps Across the Boundary

A WebAssembly call can fail in two fundamentally different ways, and treating them the same is the source of most confusing failures in a module’s interface. A trap aborts the instance mid-instruction and surfaces in JavaScript as a RuntimeError, leaving the module’s memory in whatever state it had reached. A returned error is a value the module produced deliberately, which the caller reads and acts on. The first means something is broken; the second means something did not work. Designing an interface that distinguishes them, and a host that responds correctly to each, is what this area covers.

Prerequisites

  • [ ] A module with an interface you control, or one you are integrating with.
  • [ ] A panic hook or its equivalent, so traps carry a message.
  • [ ] A clear idea of which conditions are bugs and which are expected outcomes.
  • [ ] Somewhere to report failures, because both kinds are worth counting.

The two kinds, precisely

A trap is produced by the engine: an out-of-bounds memory access, a division by zero, an unreachable instruction, a failed indirect call type check, or a stack overflow. It is not catchable inside the module — there is no mechanism for a WebAssembly function to handle its own trap — and it unwinds all the way to the host.

A returned error is ordinary data. The module computed that it could not do the job and encoded that fact in its return value or in memory the caller reads.

// a trap: the instance is now suspect
try { instance.exports.process(ptr, len); }
catch (e) { /* e instanceof WebAssembly.RuntimeError */ }

// a returned error: the instance is fine
const code = instance.exports.process(ptr, len);
if (code < 0) { /* handle it; call again with different input if you like */ }
Two failures, two consequences A trap aborts execution mid-instruction and leaves the module's memory in an unknown state, so the instance should be discarded. A returned error is a value produced deliberately, and the instance remains usable. trap out of bounds · unreachable · div by zero aborts mid-instruction memory left half-modified arrives as RuntimeError discard the instance returned error bad input · limit exceeded · not found returns normally memory consistent arrives as a value keep using the instance The distinction is not stylistic: the right response to each is different, and code that cannot tell them apart gets one of them wrong.

Every trap the engine can produce

Knowing the complete list makes a trap message immediately diagnosable, because each one points at a specific class of bug.

Out-of-bounds memory access — a load or store past the end of linear memory. Almost always a pointer computed wrongly, a length not validated, or a view built over a detached buffer.

Out-of-bounds table access — an indirect call with an index past the table’s end, or into a null slot.

Indirect call type mismatch — the function at that index has a different signature. In a correct program this cannot happen, so it indicates memory corruption of a vtable or a function pointer.

Integer division or remainder by zero — exactly what it says, and the one trap that most often surprises people, because in JavaScript dividing by zero produces Infinity rather than an error.

Integer overflow on a float-to-int conversion — converting a float outside the target integer’s range traps, unless the module uses the saturating conversion instructions.

unreachable executed — the instruction a panic, an abort or a compiler-inserted impossible case compiles to.

Stack exhaustion — too many frames, typically from unbounded recursion.

RuntimeError: memory access out of bounds
RuntimeError: table index is out of bounds
RuntimeError: indirect call signature mismatch
RuntimeError: divide by zero
RuntimeError: float unrepresentable in integer range
RuntimeError: unreachable
RuntimeError: Maximum call stack size exceeded

Those seven messages cover essentially every trap you will see, and each maps to a short list of causes. Recognising them removes the first ten minutes of most investigations.

Design the interface so failures are returned

Almost every condition a caller can trigger should be a returned error rather than a trap, because a trap costs the instance and a returned error costs a branch.

That means validating at the boundary. An exported function receiving a pointer and a length should check the length before using it, rather than letting an out-of-bounds read produce a trap several frames deeper.

#[no_mangle]
pub extern "C" fn process(ptr: *const u8, len: usize) -> i32 {
    if len == 0 { return ERR_EMPTY; }
    if len > MAX_INPUT { return ERR_TOO_LARGE; }
    let data = unsafe { std::slice::from_raw_parts(ptr, len) };
    match run(data) {
        Ok(n) => n as i32,
        Err(e) => -(e.code() as i32),
    }
}

The convention of a non-negative result for success and a negative code for failure is common, compact and easy to consume from any host. Richer schemes — a status byte in a result buffer, a separate last_error() export — work too; what matters is that the caller can distinguish success from failure without catching an exception.

Designing an error vocabulary

An interface that returns codes needs those codes to mean something consistent, and deciding the vocabulary once is what keeps it from drifting into a collection of magic numbers.

Group them by cause, because the caller’s response differs by group rather than by individual code. Input errors mean the caller should change what it sent. Limit errors mean the caller should send less, or differently. State errors mean the caller called things in the wrong order. Internal errors mean the module hit something it did not expect, which is a bug worth reporting even though it did not trap.

#[repr(i32)]
pub enum Err {
    // 1xx: the caller's input
    Empty        = -101,
    TooLarge     = -102,
    BadMagic     = -103,
    UnsupportedVersion = -104,
    // 2xx: limits
    OutOfMemory  = -201,
    TooManyItems = -202,
    // 3xx: state
    NotInitialised = -301,
    AlreadyRunning = -302,
    // 9xx: our bug, reported rather than trapped
    Internal     = -901,
}

Grouping by hundreds makes the caller’s dispatch trivial — code / 100 gives the category — and makes a new code addable without renumbering anything. Reserve a range for internal errors and report them like traps, because a module returning Internal is telling you about a bug that it happened to catch.

Document the vocabulary alongside the interface, and keep it stable. A code whose meaning changed between versions is worse than a new code, because existing callers continue to interpret it the old way.

What to do when a trap does happen

A trap means a bug, and the correct response has three parts.

Report it, with whatever context identifies the operation. A trap with no message is nearly useless, so a panic hook that captures the message is a prerequisite — see handling panics in Rust Wasm.

Discard the instance. The memory is in an unknown state: an allocator’s bookkeeping may be half-updated, a data structure half-written. Continuing may work, may produce wrong answers, or may trap again somewhere more confusing.

Recreate from the compiled module, which costs well under a millisecond and restores a known-good state. That turns a trap from a session-ending failure into a single failed operation.

let instancePromise = null;
const engine = () => (instancePromise ??= WebAssembly.instantiate(compiledModule, imports));

export async function process(input) {
  try {
    return runProcess((await engine()).instance, input);
  } catch (e) {
    if (e instanceof WebAssembly.RuntimeError) {
      instancePromise = null;
      report({ kind: 'wasm-trap', message: String(e), build: BUILD_HASH });
    }
    throw e;
  }
}

Errors from the host side

Traffic goes both ways. A host function imported into the module can throw, and that exception propagates through the WebAssembly frames to whoever called the module.

That is occasionally useful and mostly a hazard. The module’s frames unwind without any opportunity to clean up — allocations made, locks conceptually held, invariants half-restored — so a host function that throws leaves the module in much the same state a trap would.

The safer convention is for host functions to report failure in their return value, exactly as the module’s exports do:

const imports = {
  host: {
    read_config: (ptr, cap) => {
      try {
        const bytes = encodeConfig();
        if (bytes.length > cap) return -1;              // too big: the module decides what to do
        new Uint8Array(memory.buffer, ptr, bytes.length).set(bytes);
        return bytes.length;
      } catch {
        return -2;                                       // report, do not throw
      }
    },
  },
};
Host functions should report, not throw An exception thrown from an imported function unwinds through the module's frames without cleanup, leaving it in an inconsistent state. Returning a status lets the module handle the failure and maintain its invariants. host import throws module calls in host throws frames unwind, no cleanup runs host import returns a status module calls in returns -1 module handles it, invariants hold The same argument as for the module's own exports, applied in the other direction — and just as often overlooked.

Errors that cross an asynchronous boundary

When the module runs in a worker, both kinds of failure have to be serialised and re-created on the other side, and the naive implementation loses the distinction.

An Error object does not survive postMessage with its prototype intact, so e instanceof WebAssembly.RuntimeError on the main thread is always false if the error was simply forwarded. The fix is to classify on the worker side, where the type information still exists, and send a tag.

// in the worker, where the distinction is still available
try {
  const value = runProcess(input);
  self.postMessage({ id, ok: true, value });
} catch (e) {
  self.postMessage({
    id,
    ok: false,
    kind: e instanceof WebAssembly.RuntimeError ? 'trap' : 'host-error',
    message: String(e && e.message || e),
    stack: String(e && e.stack || '').slice(0, 4000),
  });
}
// on the main thread, reconstructing the distinction
if (!msg.ok && msg.kind === 'trap') {
  await recycleWorker();               // the worker's instance is suspect
  throw new WasmFaultError(msg.message, msg.stack);
}

Recycling the worker is the asynchronous equivalent of discarding the instance, and it is simpler: the worker holds the instance, so terminating and recreating it restores a clean state without any per-instance bookkeeping. The cost is a few milliseconds, which for a failure path is nothing.

The same pattern applies to a module running under a server runtime behind a request handler: classify at the point where the type information exists, carry the classification in the response, and let the caller decide what it means.

Distinguishing the two in a catch block

At the call site, the two arrive differently and should be handled differently.

try {
  const r = await callModule(input);
  if (r.status !== 0) return { kind: 'rejected', code: r.status };   // returned error
  return { kind: 'ok', value: r.value };
} catch (e) {
  if (e instanceof WebAssembly.RuntimeError) {
    discardInstance();
    return { kind: 'fault', error: String(e) };                      // trap
  }
  return { kind: 'host-error', error: e };                           // our own bug
}

Three outcomes, each with a different meaning for the user, for the logs and for whether the next call can proceed. Collapsing them into one catch block that shows “something went wrong” throws away the information that makes the system operable.

Presenting failures to a user

The distinction that matters internally should mostly disappear by the time it reaches a person, and deciding what each kind becomes in the interface is part of the design rather than an afterthought.

A returned error usually maps to something specific and actionable: “this file is larger than the 50 MB limit”, “this format is not supported”, “the coupon code was not recognised”. The module supplied a code, the interface supplies the sentence, and the user knows what to change. Formatting on the host side rather than in the module is what keeps the message translatable and keeps formatting machinery out of the binary.

A trap maps to something apologetic and non-specific, because there is nothing the user did that they can undo: “something went wrong processing that file — we have logged it”. Attempting to explain a trap to a user is not possible in useful terms, and attempting to guess a cause from the message usually produces a sentence that is wrong.

const MESSAGES = {
  '-101': 'The file appears to be empty.',
  '-102': 'That file is larger than the 50 MB limit.',
  '-103': 'That does not look like a supported file.',
};

function present(result) {
  if (result.kind === 'rejected') {
    return MESSAGES[String(result.code)] ?? 'We could not process that file.';
  }
  if (result.kind === 'fault') {
    return 'Something went wrong processing that file. It has been reported.';
  }
  return null;
}

The fallback in the lookup matters: a code the interface does not recognise — because the module is newer than the page, or because someone added a code and not a message — should produce a generic sentence rather than undefined. That is the same defensive habit as everywhere else in this area, applied at the last step.

Four failure kinds, four handlers A validation error happens before the module runs. A trap aborts the current call. A returned error code is ordinary control flow. A panic poisons the instance. validation error at compile or instantiate time; nothing has run yet trap the call aborts; JavaScript sees a RuntimeError returned error code ordinary control flow the caller inspects panic the instance is poisoned and must be discarded Only the third is recoverable in the ordinary sense; the others end the call and often the instance. Design so that expected failures are the third kind, and the other three mean a genuine bug.

Gotchas and failure modes

  • Treating a trap as a recoverable error. The instance is suspect; reuse produces wrong answers.
  • Using a trap to signal an expected condition. Costs the instance for something that was not a bug.
  • No panic hook. Every trap is unreachable executed with no message.
  • Host imports that throw. Unwinds the module without cleanup; return a status instead.
  • Catching without distinguishing. A RuntimeError and a TypeError mean completely different things.
  • Not reporting returned errors. A rising rate of rejected inputs is a signal about your users’ data.

Verifying the failure paths

Failure paths are the least exercised code in a module and the most likely to be wrong. Test them deliberately, with fixtures that trigger each one.

test('rejects oversized input without trapping', async () => {
  const r = await callModule(new Uint8Array(MAX_INPUT + 1));
  expect(r.kind).toBe('rejected');
  expect(await callModule(smallValidInput)).toMatchObject({ kind: 'ok' });  // instance still healthy
});

test('recovers from a trap', async () => {
  await expect(callModule(INPUT_THAT_TRAPS)).resolves.toMatchObject({ kind: 'fault' });
  expect(await callModule(smallValidInput)).toMatchObject({ kind: 'ok' });  // fresh instance works
});

The second assertion in each is the important one: after a failure of either kind, the next ordinary call must succeed. A module that works until the first bad input and then fails every subsequent call is a common and entirely avoidable outcome.

Guides in this topic

Frequently Asked Questions

Where should the error vocabulary live? In the shared crate or header that both sides read, so the module’s codes and the host’s messages cannot drift apart independently.

Can a module catch its own trap? No. There is no mechanism for it, which is deliberate — a trap indicates a condition the module’s own invariants do not cover. The exception-handling proposal adds catchable exceptions, which are a different thing from traps.

Does the component model change how errors cross? It makes the result type part of the interface definition, so a fallible operation declares its error type and the generated bindings carry it across without a hand-written convention. The distinction between a declared failure and a trap remains exactly as described here.

Is a trap a security problem? No. It is the sandbox working: an out-of-bounds access traps rather than reading memory it should not. It is a correctness problem and often a denial-of-service one, and it is contained.

Should I use exceptions instead of error codes? For a C++ module’s internals, yes. For its interface, a returned status is simpler for every caller and avoids the payload-interpretation problem — as discussed in exception handling in WebAssembly.

How do I know whether an instance is still usable? Assume it is not, after a trap. There is no way to inspect the module’s invariants from outside, and recreating is cheap enough that assuming the worst costs nothing.

Should returned errors be logged as aggressively as traps? Counted, yes; logged individually, usually not. A steady rate of input errors is normal and tells you about your users’ data; a spike is a signal. A trap is always worth an individual report, because each one is a distinct bug.

What about errors in an import the module calls during instantiation? The start function runs during instantiation, so a failure there surfaces as an instantiation error rather than a call error — and the instance never exists. Handle instantiation failures separately from call failures; the responses are different.

Get the distinction right once, at the interface, and everything downstream — logging, retries, user messages, recovery — follows from it without further thought.

← Back to JS/Wasm Interop & Memory Management