Handling Panics in Rust Wasm

This guide answers one task: make a Rust panic inside a WebAssembly module produce a diagnosable error rather than an opaque runtime failure — and decide what the application does afterwards.

Prerequisites

  • [ ] A Rust crate compiled to wasm32-unknown-unknown.
  • [ ] console_error_panic_hook as a dependency.
  • [ ] Somewhere to send errors: a console during development, telemetry in production.
  • [ ] An opinion about whether a panic should be recoverable in your application.

What a panic actually does

In a WebAssembly module built with panic = "abort" — which almost every browser module uses — a panic executes an unreachable instruction. The engine traps, JavaScript sees a RuntimeError, and the message is:

RuntimeError: unreachable executed
    at :wasm-function[412]:0x1f3a2

No message, no file, no line. The panic’s own text was formatted and then discarded, because nothing was listening.

Worse, the instance is now in an unknown state. The panic happened partway through a function, so whatever that function was modifying — an allocator’s bookkeeping, a data structure, a half-written buffer — is inconsistent. The module did not unwind and did not clean up.

What the caller sees, and what is left behind A panic formats a message, aborts, and executes an unreachable instruction. JavaScript receives a RuntimeError with no detail, and the module's memory is left in whatever state the interrupted function had reached. panic!(...) message formatted panic hook the only chance to see it unreachable trap, no message RuntimeError in JavaScript Without the hook installed, the second box does nothing and the message is lost between the first and the third. The instance's memory keeps whatever the interrupted function left there, which is why continuing to use it is unsafe.

Install the hook

console_error_panic_hook replaces Rust’s panic handler with one that writes the message and a stack trace to the console before aborting. It costs a few kilobytes and turns an unusable error into a readable one.

[dependencies]
console_error_panic_hook = "0.1"
use wasm_bindgen::prelude::*;

#[wasm_bindgen(start)]
pub fn start() {
    console_error_panic_hook::set_once();
}
panicked at 'index out of bounds: the len is 32 but the index is 41', src/parse.rs:87:13

Stack:
  engine::parse::read_record@http://localhost:8080/engine.js:412:19
  engine::process@http://localhost:8080/engine.js:498:7

Install it in a start function so it applies before any other code runs. Installing it lazily means the first panic — often during initialisation — is the one you cannot see.

For production, the hook can report to telemetry instead of, or as well as, the console:

std::panic::set_hook(Box::new(|info| {
    let msg = info.to_string();
    report_panic(&msg);                        // a JS import that sends it to your collector
}));

Do not panic on expected failures

A panic is for a bug. Invalid input, a missing file, a value out of range and a failed allocation are not bugs — they are conditions the caller should be told about, and a Result tells them.

// wrong: a bad length from the caller aborts the instance
#[wasm_bindgen]
pub fn process(ptr: *const u8, len: usize) -> u32 {
    let data = unsafe { std::slice::from_raw_parts(ptr, len) };
    let header = &data[0..16];                 // panics if len < 16
    …
}

// right: the caller gets an error and the instance survives
#[wasm_bindgen]
pub fn process(ptr: *const u8, len: usize) -> Result<u32, JsValue> {
    if len < 16 { return Err(JsValue::from_str("input too short")); }
    let data = unsafe { std::slice::from_raw_parts(ptr, len) };
    …
}

Auditing for panics is mostly auditing for indexing, unwrap, expect and integer arithmetic that can overflow in debug builds. Clippy’s indexing_slicing and unwrap_used lints make the audit mechanical, and enabling them for the module’s crate — even as warnings — surfaces the places where a caller can trigger an abort.

Finding the panics before users do

A panic reaching production is a bug that escaped review, and several tools make the audit systematic rather than hopeful.

Clippy’s lints are the cheapest starting point. Enabling them for the module’s crate produces a list of every place a panic is possible, which is longer than most people expect on first run:

#![warn(clippy::indexing_slicing)]
#![warn(clippy::unwrap_used)]
#![warn(clippy::expect_used)]
#![warn(clippy::panic)]
#![warn(clippy::integer_arithmetic)]

Work through them at the boundary first. A panic deep inside a pure function that only ever receives validated data is far less dangerous than one in the first ten lines of an exported function, where the caller’s input reaches it directly. Validating at the boundary and using total operations inside is the structure that removes most of the list.

Fuzzing finds the rest, because it generates the inputs nobody thought to validate against — the guide on fuzzing a Wasm module covers the setup, and every crash it reports is a panic that would otherwise have reached a user.

Finally, count them in production. A telemetry counter for traps, broken down by module version, tells you whether the audit worked. A rate that is not zero is a list of bugs; a rate that rises after a release is a regression you can attribute immediately.

Recovering, or not

Once a panic has happened, the instance should be considered unusable. Continuing to call into it may work, may return wrong answers, or may panic again in a more confusing place.

The clean recovery is to discard the instance and create a new one. From an already-compiled module that costs well under a millisecond, which makes it an entirely reasonable response to an error.

let instancePromise = null;
function engine() {
  instancePromise ??= WebAssembly.instantiate(compiledModule, imports);
  return instancePromise;
}

export async function process(input) {
  try {
    const inst = await engine();
    return runProcess(inst, input);
  } catch (e) {
    if (e instanceof WebAssembly.RuntimeError) {
      instancePromise = null;                  // discard; the next call gets a fresh instance
      report({ kind: 'wasm-trap', message: String(e) });
    }
    throw e;
  }
}

That pattern — catch, discard, report, rethrow — keeps a single bad input from breaking the feature for the rest of the session, which is a meaningful difference from the user’s point of view.

Discard rather than continue After a trap the instance's memory is inconsistent. Discarding it and instantiating again from the already-compiled module costs under a millisecond and restores a known-good state. trapped instance memory inconsistent discard + report telemetry gets the message fresh instance < 1 ms, known-good state feature keeps working The compiled module is unaffected by a trap — only the instance is — which is what makes recovery this cheap.

Arithmetic overflow, which behaves differently

One source of panics catches people out because it depends on the build profile. In debug builds, Rust checks integer arithmetic for overflow and panics; in release builds it wraps silently by default.

That means a module can pass every test in development and produce wrong answers in production, or the reverse — a panic that only appears in a debug build and is dismissed as a development artefact when it is in fact a real bug with a real consequence.

Decide explicitly rather than inheriting the default:

[profile.web]
inherits = "release"
overflow-checks = true          # keep the check in the shipping build

With checks on, an overflow becomes a trap, which the recovery path above handles and telemetry reports. With them off, it wraps, and a size calculation that overflows produces a small allocation followed by an out-of-bounds write — contained by the sandbox, and still wrong.

For arithmetic where wrapping is intended, say so in the code with wrapping_add and its relatives, so the intent survives a change of profile. For arithmetic on values derived from input, use checked_add and handle the None, which converts the whole class of problem into an ordinary error return.

Expected output

With the hook installed and a Result-returning interface, the two kinds of failure look completely different from the outside:

// an expected failure — handled, instance still healthy
Error: input too short
  at process (engine.js:214)

// a bug — reported, instance discarded
panicked at 'index out of bounds: the len is 32 but the index is 41', src/parse.rs:87:13
Stack: engine::parse::read_record ...
[telemetry] wasm-trap reported, instance recreated

That distinction is what makes production diagnosis possible. A log full of unreachable executed says only that something broke; a log with panic messages and file positions points at a line.

Three panic settings, three outcomes The default aborts with an unhelpful message. The console error panic hook prints a real message and stack. Building with panic equal to abort removes the unwinding machinery and shrinks the binary. default build unreachable executed — no message, no location console_error_panic_hook panic message, file and line, and a usable stack trace panic = "abort" smaller binary, no unwinding message still needs the hook Install the hook once at startup, behind a debug_assertions guard if you would rather not ship the formatting. A panic is not a catchable exception: the instance is poisoned, and the only safe response is to discard it.

Gotchas

  • Hook not installed. Every panic is unreachable executed with no message.
  • Hook installed lazily. The initialisation panic — the most common one — is the one you cannot see.
  • Continuing to use a trapped instance. Undefined behaviour; discard it.
  • Panicking on caller error. Turns a handled condition into an aborted instance.
  • The hook stripped in release. Understandable for size, and it removes production diagnosis; prefer a reporting hook over none.
  • catch_unwind with panic = "abort". Does nothing; the abort happens first.

Performance note

console_error_panic_hook adds roughly 8–14 kB compressed, most of it formatting machinery for the message. A custom hook that forwards a static string and a location to a JavaScript import costs under a kilobyte, which is a reasonable compromise for a size-sensitive release build. Recreating an instance after a trap took 0.4 ms from the cached compiled module, small enough that recovery is essentially free.

Frequently Asked Questions

Should I ship the panic hook in production? Ship a hook, not necessarily that hook. A production build benefits enormously from panic messages reaching telemetry, and a minimal custom hook gives you that without the console formatting weight.

Can I catch a panic inside the module? Not with panic = "abort", which is the standard configuration. With panic = "unwind" and catch_unwind it is possible, at a substantial size cost and with the caveat that the caught state is still suspect. Returning Result is almost always the better design.

How do I get file and line information in release? Keep debug information in a separate build used for diagnosis, or accept function names by not stripping the name section. The position information comes from DWARF, which is large — many teams ship stripped and keep an unstripped artifact for symbolication.

← Back to Rust to Wasm Compilation Guide