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_hookas 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.
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.
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.
Gotchas
- Hook not installed. Every panic is
unreachable executedwith 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_unwindwithpanic = "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.
Related
- Catching wasm traps in JavaScript — the caller’s side of this.
- Propagating Rust Results to JavaScript — the interface that avoids panics.
- Fuzzing a Wasm module — finding the inputs that panic before users do.
← Back to Rust to Wasm Compilation Guide