Adding Context to Errors from Wasm

This page answers one task: errors from a WebAssembly module reach your logs as “RuntimeError: unreachable”, “invalid input” or “error code 7”, and nobody can tell which operation failed, on what kind of input, in which build. You want errors that carry enough context to diagnose them without reproducing them locally.

Prerequisites

  • [ ] A Wasm module whose errors reach JavaScript (via wasm-bindgen Result, error codes, or traps).
  • [ ] An error-reporting pipeline (Sentry, a logging endpoint, or similar).
  • [ ] Control over the JavaScript wrapper around the module.

What context a Wasm error needs

A useful error answers four questions: what failed (the operation and the specific error kind), where (the function, ideally with a symbolicated stack), on what (the shape of the input — size, type, version — never the content), and in which build (module version or content hash). Errors crossing the Wasm boundary often lose all four. A Rust Result converted with JsError::new(&e.to_string()) keeps a message but loses the error kind and source chain. An error code from C keeps a number. A trap keeps the trap kind and a stack of Wasm function indices, which are meaningless without names.

The fix is layered: preserve structure on the Wasm side, convert it into a structured JavaScript error at the boundary, and attach operation and build context in the wrapper, where it is known.

Layers of context on an error from Wasm The Wasm side supplies the error kind and a message. The boundary converts it into a JavaScript Error with a code property. The wrapper adds the operation name and input shape. The reporting layer adds the module version and symbolicated stack before sending. reporting layer module version, hash, stack JS wrapper operation + input shape boundary conversion Error with code + cause Wasm side error kind + message

Step 1 — keep error kinds structured in Rust

Define an error enum and keep it structured until the boundary, using thiserror for messages and #[source] for chains:

#[derive(Debug, thiserror::Error)]
pub enum DecodeError {
    #[error("unsupported format version {0}")]
    UnsupportedVersion(u16),
    #[error("truncated input: needed {needed} bytes at offset {offset}")]
    Truncated { needed: usize, offset: usize },
    #[error("invalid chunk {tag:?}")]
    InvalidChunk { tag: [u8; 4], #[source] cause: ChunkError },
}

impl DecodeError {
    pub fn code(&self) -> &'static str {
        match self {
            Self::UnsupportedVersion(_) => "UNSUPPORTED_VERSION",
            Self::Truncated { .. } => "TRUNCATED",
            Self::InvalidChunk { .. } => "INVALID_CHUNK",
        }
    }
}

Messages include structural details — offsets, sizes, tags — that help diagnosis without containing user content.

Step 2 — convert to a JavaScript error with a code

At the boundary, build a JavaScript Error with a stable code property and the full source chain in the message:

use wasm_bindgen::prelude::*;

fn to_js(e: DecodeError) -> JsValue {
    let mut msg = e.to_string();
    let mut src = std::error::Error::source(&e);
    while let Some(s) = src { msg.push_str(&format!(": {s}")); src = s.source(); }
    let err = js_sys::Error::new(&msg);
    js_sys::Reflect::set(&err, &"code".into(), &e.code().into()).unwrap();
    err.into()
}

#[wasm_bindgen]
pub fn decode(bytes: &[u8]) -> Result<Image, JsValue> {
    decode_inner(bytes).map_err(to_js)
}

JavaScript code can now branch on err.code instead of parsing messages, and logs show the whole chain: “invalid chunk “IDAT”: crc mismatch”.

Step 3 — add operation and input shape in the wrapper

The JavaScript wrapper knows what the user was doing. Wrap errors in a small error class that uses Error.cause to keep the original error and adds structured fields describing the operation and the input’s shape — information that explains failures without identifying the user:

class WasmOperationError extends Error {
  constructor(operation, context, cause) {
    super(`${operation} failed: ${cause?.message ?? cause}`, { cause });
    this.name = "WasmOperationError";
    this.operation = operation;
    this.code = cause?.code ?? (cause instanceof WebAssembly.RuntimeError ? "TRAP" : "UNKNOWN");
    this.context = context;
  }
}

export async function decodeImage(file) {
  const bytes = new Uint8Array(await file.arrayBuffer());
  try {
    return wasm.decode(bytes);
  } catch (e) {
    throw new WasmOperationError("decodeImage", {
      sizeBytes: bytes.length,
      mime: file.type,
      magic: Array.from(bytes.slice(0, 4), (b) => b.toString(16).padStart(2, "0")).join(""),
    }, e);
  }
}

The first four bytes identify the format without revealing content; size and type explain most decode failures.

An error gaining context on its way to the log The Rust decoder returns InvalidChunk with a crc mismatch cause. The boundary turns it into a JavaScript Error with code INVALID_CHUNK and the chained message. The wrapper wraps it as WasmOperationError with the operation name, input size and type. The reporter adds the module hash before sending. Rust error InvalidChunk ← crc mismatch JS Error code: INVALID_CHUNK wrapper error decodeImage, 4.2 MB, png reporter + module hash, stack log entry diagnosable

Step 4 — give traps names and versions

Traps (RuntimeError: unreachable, memory access out of bounds) carry a stack of Wasm frames. Keep the name section in production builds — it costs some size but makes frames readable — or upload a symbol map to your error tracker so it can symbolicate. Attach the module’s build identifier to every report: export a version() function or embed the content hash in the loader, and add it as a tag. A trap stack such as at app::decode::read_chunk (wasm-function[412]) plus a module hash identifies the exact code path.

Step 5 — keep user data out

Context must not become a privacy leak. Never attach input content, file names, text snippets or decoded values; attach sizes, types, counts, format versions and hashes of structural markers. Review the context fields in code review like any other data collection, and scrub messages from the Wasm side that might include content — for example a parser error that quotes the offending token. Prefer offsets and lengths in messages to quoted input.

Grouping errors usefully

Error trackers group events by message and stack. Messages that include variable details — “truncated input: needed 4 bytes at offset 1038” — split one bug into thousands of groups. Keep variable details out of the message used for grouping (the code property or a fixed message) and put them in structured context fields, which the tracker shows on each event without fragmenting groups. Most trackers let you set an explicit fingerprint; use the operation plus code.

Errors from C and C++ modules

For C modules returning integer codes, map each code to a string and message in the wrapper, and have the C side record extra context in a small “last error” buffer that the wrapper reads after a failure — the function name, the offset, a short detail string. That mimics errno-style APIs but gives the wrapper enough to build a structured error. C++ exceptions crossing into JavaScript need their message extracted explicitly; see handling C++ exceptions from Wasm in JavaScript.

Recording what happened before the error

The state at the moment of failure is often less informative than the steps that led to it. A small ring buffer of recent operations — “loaded document (812 KB), applied 14 edits, started export” — attached to each error report gives the sequence without full logging. Keep the ring buffer in JavaScript, where the wrapper already sees every call, and record operation names, durations and input shapes, with the same privacy rules as error context. When the module traps, the breadcrumbs often show the pattern immediately: the trap follows a particular call sequence, or only happens after memory grew past a threshold. Error trackers support breadcrumbs natively; feed them from the wrapper rather than from scattered call sites so every Wasm call is covered.

Panic messages and locations from Rust

A Rust panic becomes RuntimeError: unreachable in JavaScript, with the panic message printed to the console by console_error_panic_hook and lost everywhere else. Install a custom panic hook that stores the message and location in a place the wrapper can read — a JavaScript callback imported into the module, or a static string buffer — and have the wrapper attach it to the trap report. The location (src/decode.rs:214:18) plus the module hash identifies the exact line, which is usually enough to fix the bug without reproducing it. Keep messages free of user data, as panics from expect calls sometimes include values formatted with {:?}.

Making context consistent across a codebase

Context is only useful if every operation provides it in the same shape. Route all Wasm calls through one wrapper module that applies the error class, breadcrumbs and build tags, rather than wrapping errors ad hoc at call sites. Then a new exported function gains full context automatically, and the reporting pipeline can rely on the fields being present.

Expected output

A decode failure in production appears as WasmOperationError: decodeImage failed: invalid chunk "IDAT": crc mismatch with code INVALID_CHUNK, context { sizeBytes: 4194304, mime: "image/png", magic: "89504e47" }, module hash tag a41f9c2, and a symbolicated stack — grouped with other INVALID_CHUNK decode failures rather than as a new issue per offset.

Gotchas

  • Stringifying errors at the boundary. Kinds and chains are lost. Convert structurally with a code.
  • Stripped names in production. Trap stacks become indices. Keep names or upload symbols.
  • Variable details in messages. Error groups fragment. Use structured fields.
  • User content in context. A privacy problem. Attach shapes, not content.
  • No build identifier. Errors cannot be matched to code. Tag every report with the module hash.

Performance note

Building context only on the error path costs nothing on success. Keeping the name section added 38 KB uncompressed (9 KB with Brotli) to a 610 KB module — the price of readable trap stacks.

Cost of keeping function names in production Brotli-compressed size of a module with the name section stripped and with it kept for readable trap stacks. KB compressed names stripped 171 KB names kept 180 KB

Frequently Asked Questions

Should every error carry a code? Every error JavaScript might handle differently, yes; codes are stable where messages change.

Is Error.cause widely supported? Yes, in all current browsers and Node.

Can I keep names without shipping them? Upload a symbol map to the error tracker and strip names from the shipped module.

What about panics? Install a panic hook that records the message and location, and attach it to the trap report.

How do I see the Rust panic message in production reports? Install a panic hook that stores the message and location where the JavaScript wrapper can read it, and attach it to the trap report.

← Back to Errors & Traps Across the Boundary