Skip to content

S2: console_error_panic_hook — making Rust panics visible in the browser

Taught: 2026-04-29 (during M1.5 Debug Overlay milestone) Milestone: Phase 1 M1.5 — Debug Overlay Result: PASS (after conversational re-teach; original compact format was rejected by the user) Backfilled: 2026-05-04

Why this concept matters here

When Rust code panics in a wasm module, the default behavior is "abort and print nothing useful to the JS console" — you get a generic RuntimeError: unreachable executed with no message, no file, no line. Debugging is miserable. console_error_panic_hook::set_once() swaps in a Rust panic hook that pushes panic messages to the browser's DevTools console with a stack trace. One-line install, transforms wasm debugging from impossible to reasonable.

The walkthrough

Step 1: What is a Rust panic?

A "panic" in Rust is what happens when something goes catastrophically wrong and the program decides it can't continue safely:

  • An array index out of bounds
  • A .unwrap() on None or Err
  • An explicit panic!("message") call
  • An assertion fails (assert_eq! mismatch)

When this happens, Rust's runtime does three things:

  1. Captures information about the panic — message, file, line number, optionally a stack backtrace
  2. Calls a global callback (called the "panic hook") with that info
  3. Either unwinds the stack or aborts, depending on config

That global callback in step 2 — the panic hook — is where today's lesson lives.

Step 2: The panic hook is a swappable callback

Rust's std::panic::set_hook lets you replace the global panic-handling function. The signature is roughly:

rust
fn set_hook(hook: Box<dyn Fn(&PanicInfo) + Send + Sync + 'static>);

Whatever closure you pass becomes the new "thing that runs on panic." PanicInfo has methods like .message(), .location().file(), .location().line().

The default hook (when you don't set your own) writes the panic message to stderr — your terminal's standard error stream. That's why when you run cargo run and panic, you see:

thread 'main' panicked at 'index out of bounds: 5 out of 4', src/main.rs:42:5

Convenient. Useful. Helps you debug.

Step 3: Why the default hook is useless in wasm

Now: a browser has no stderr. There is no terminal. There is no file descriptor 2. The default panic hook writes to a stream that doesn't exist in a wasm runtime.

What actually happens when Rust-in-wasm panics with the default hook:

  1. The panic fires. PanicInfo is formatted.
  2. The default hook tries to write to stderr. Nothing happens (the bytes go nowhere).
  3. The panic continues — it tries to unwind, but wasm32-unknown-unknown is configured panic = "abort", so instead of unwinding it traps.
  4. The wasm runtime sees the trap and tells the browser: "wasm code aborted."
  5. The browser's JS engine surfaces this as a generic RuntimeError: unreachable executed.

The panic message — your actual "index out of bounds: 5 out of 4" — was formatted in step 2 and immediately discarded because nothing was listening to stderr. By the time you see "unreachable executed" in DevTools, the message is gone.

This is what makes vanilla wasm debugging miserable. Every panic looks identical: RuntimeError. You have no idea where it came from.

Step 4: What console_error_panic_hook does

The fix is to install a smarter panic hook that knows about the browser. Specifically: a hook that takes the same PanicInfo and instead of writing to stderr, calls console.error() in JavaScript.

Conceptually, the crate's set_once() does this:

rust
std::panic::set_hook(Box::new(|info: &PanicInfo| {
    let msg = format!(
        "panicked at {}:{}\n  {}",
        info.location().unwrap().file(),
        info.location().unwrap().line(),
        info.to_string()
    );
    // Call into the browser's console.error via wasm-bindgen FFI:
    web_sys::console::error_1(&msg.into());
}));

After this is installed, when Rust panics:

  1. PanicInfo is built (same as before)
  2. Our hook fires (instead of the default)
  3. It formats the panic with file/line/message
  4. It calls console.error() in JS, which the browser's DevTools shows
  5. THEN the panic continues to abort (you still crash — but now you know why)

You go from RuntimeError: unreachable (useless) to:

panicked at crates/app-render/src/lib.rs:142
  bind group layout mismatch: expected 2 entries, got 1

in the DevTools console. The crash still happens — the hook doesn't recover from panics, it just makes them visible.

Step 5: Why set_once?

The crate exposes a few entry points:

  • set() — installs the hook every time it's called. Calling it twice replaces the previous hook.
  • set_once() — uses std::sync::Once internally so it only installs once, no matter how many times you call it.

You want set_once() because you'll likely call it from Engine::create_impl — and create_impl could be called multiple times if the engine is re-created (e.g., resize triggers tear-down + rebuild). set_once makes that safe and idempotent.

The whole installation is one line:

rust
pub async fn create_impl(canvas: HtmlCanvasElement) -> Result<Engine, JsValue> {
    console_error_panic_hook::set_once();   // first thing — before anything can panic
    // ... rest of init
}

Put it first in the init path so panics during request_adapter, create_surface, etc. are visible.

The mental model in one sentence

Rust has a swappable global "what to do when something panics" callback. The default one writes to a stream the browser doesn't have. console_error_panic_hook swaps in a callback that writes to the browser's DevTools console instead. One line install. Doesn't prevent the crash, just lets you see the message.

Explain-back question

If you forgot to call set_once() and your renderer panicked deep inside Renderer::new() because of a bad bind group layout, what would you see in the browser console, and why does the default behavior fail to surface the actual Rust panic message?

User's answer (PASS)

No, we will not see in browser console because it writes to stderr. With panic hook it writes to JS console in the browser that I can use to debug if something panics/crashes in the future!

Judgment

PASS. Got:

  • Why default fails: writes to stderr, which doesn't exist in browser → message lost
  • What the panic hook does: redirects to JS console
  • Why it matters: makes panics debuggable

Showed WHY (default hook writes to a stream the browser doesn't have), not just WHAT.

Conversational walkthroughs of real-time graphics + Rust/WASM concepts.