Removing Panic and Formatting Bloat from Rust Wasm

This page answers one task: your Rust WebAssembly module is much larger than the code you wrote, a size report shows core::fmt and panic-related functions near the top, and you want to know where they come from and how to get rid of them.

Prerequisites

  • [ ] A release build with the size settings already applied: opt-level = "z", LTO, panic = "abort".
  • [ ] twiggy (cargo install twiggy) and a build that keeps the name section for analysis.
  • [ ] Willingness to change some unwrap() calls — this is a code change, not a flag.

Why formatting is the biggest hidden cost

Rust’s formatting machinery — core::fmt — is general, flexible and large. It handles width, precision, alignment, Debug and Display for every type, padding, and float formatting, which alone is several kilobytes of tables and code. In a native binary nobody notices a few dozen kilobytes. In a WebAssembly module that should be 50 KB, formatting can be a third of the total.

The trap is that you rarely call formatting directly. It arrives through panics. Every unwrap(), expect(), slice index and integer overflow check has a panic path, and the panic path formats a message: “called Option::unwrap() on a None value”, “index out of bounds: the len is 4 but the index is 7”. With panic = "abort" the unwinding machinery is gone, but the message formatting is not — the message is still built before aborting, because the panic hook might print it. One expect("...") with a {} argument anywhere in the program is enough to keep the float formatter alive.

How one unwrap keeps the formatter in the binary A call to unwrap has a None branch that calls the panic function with a message. The panic path formats the message with core::fmt, which pulls in the formatting machinery and, through Display implementations, float and integer formatting tables. Removing every such path lets dead code elimination drop core::fmt. .unwrap() / v[i] your code or a dependency panic branch None or out-of-bounds panic_fmt(args) message with arguments core::fmt Formatter, padding, Display float + int tables kilobytes of data Dead code elimination can only remove core::fmt once no reachable path calls it.

Step 1 — confirm the diagnosis

Run twiggy on the module before stripping names, and look at the top entries and at what keeps them alive:

cargo build --release --target wasm32-unknown-unknown
wasm-bindgen target/wasm32-unknown-unknown/release/app.wasm --out-dir pkg --target web --keep-debug
twiggy top -n 15 pkg/app_bg.wasm
 Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼─────────────────────────────────────────────
          9312 ┊     6.81% ┊ core::fmt::float::float_to_decimal_common_shortest
          7204 ┊     5.27% ┊ core::fmt::Formatter::pad_integral
          5118 ┊     3.74% ┊ <&T as core::fmt::Display>::fmt
          4870 ┊     3.56% ┊ core::fmt::write
          3922 ┊     2.87% ┊ core::panicking::panic_fmt
          2410 ┊     1.76% ┊ core::fmt::num::imp::fmt_u64

Then find who keeps the biggest one alive:

twiggy paths pkg/app_bg.wasm 'core::fmt::float::float_to_decimal_common_shortest' | head -20

The paths output walks back from the function to its callers until it reaches an export. Somewhere in that chain is a line of your code — or a dependency’s — that formats a float, usually inside an error message. The full twiggy workflow is in analyzing Wasm size with twiggy.

Step 2 — replace panicking calls on hot paths

The direct fix is to stop creating panic paths with formatted messages. Each pattern has a non-panicking equivalent:

// before: each of these has a formatted panic path
let first = items[0];
let v = map.get(&key).unwrap();
let n: u32 = text.parse().expect("expected a number");
let total = a + b;                       // overflow check in debug, not in release

// after: errors flow back to the caller, no formatting
let first = *items.first().ok_or(Error::Empty)?;
let v = map.get(&key).ok_or(Error::MissingKey)?;
let n: u32 = text.parse().map_err(|_| Error::NotANumber)?;
let total = a.checked_add(b).ok_or(Error::Overflow)?;
Panicking patterns and their non-panicking replacements Common Rust patterns that create formatted panic paths, the message each one formats, and the replacement that returns an error instead. pattern formats a message replacement slice[i] index out of bounds: len, index .get(i).ok_or(..)? .unwrap() called unwrap on None/Err .ok_or(..)? / ? .expect("…{}") your message + Debug of error .map_err(..)? a + b (overflow checks on) attempt to add with overflow checked_add(..).ok_or(..)? format!("{}", x) in errors Display for the value static message + code

Returning a small error enum and converting it to a JavaScript error at the boundary keeps errors useful without formatting inside the module. The JavaScript side can build a readable message from an error code far more cheaply, as described in returning error codes without exceptions.

Step 3 — avoid Debug derives and format! in shipped code

#[derive(Debug)] on a type generates a Debug implementation that uses the formatter; that is harmless unless something calls it, but logging and error conversion often do. format!, to_string() on numbers, and {:?} in any reachable code keep the formatter alive.

// keeps core::fmt alive: number formatting for a message
return Err(JsError::new(&format!("bad width {}", w)));

// does not: a static message, and the number travels as data
return Err(JsError::new("bad width"));

For the cases where you need formatted output — a report, a CSV export — consider whether the JavaScript side can format instead. JavaScript already has number formatting built in and costs nothing to download.

Step 4 — use panic_immediate_abort for the last mile

Even after removing your own panic paths, the standard library and dependencies have some. On nightly, you can rebuild the standard library with panic_immediate_abort, which turns every panic into an immediate unreachable trap without building a message:

# .cargo/config.toml (nightly)
[unstable]
build-std = ["std", "panic_abort"]
build-std-features = ["panic_immediate_abort"]
cargo +nightly build --release --target wasm32-unknown-unknown

This removes the last references to the panic formatter, and often the formatter itself if nothing else uses it. The cost is diagnosability: a panic in production becomes a bare unreachable trap with no message. Pair it with good error returns at the boundary and with symbolicated stack traces, as described in symbolicating Wasm stack traces in production.

Module size as panic and formatting paths are removed A Rust module's Brotli-compressed size after each step: the baseline release build, replacing unwraps on hot paths with error returns, removing format! from error messages, and rebuilding std with panic_immediate_abort. Brotli-compressed size (KB) after wasm-opt -Oz baseline release build 64 KB unwrap/index → Result 52 KB no format! in errors 41 KB build-std panic_immediate_abort 29 KB The module's own logic was about 22 KB compressed; the rest of the baseline was formatting and panic machinery.

Step 5 — keep it from coming back

Size wins from code changes erode one convenient unwrap() at a time. Two guards help. A size budget in CI fails the build when the module grows past a threshold, as in catching size regressions in CI. And a targeted check fails when the formatter reappears, which gives a much clearer message than a raw size number:

if twiggy top -n 200 pkg/app_bg.wasm | grep -q 'core::fmt::float'; then
  echo "✗ float formatting is back in the module — run: twiggy paths pkg/app_bg.wasm core::fmt::float::float_to_decimal_common_shortest"
  exit 1
fi

Clippy can help too: #![deny(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] in the crate’s root makes new panic paths a compile error in your own code.

Dependencies are part of the story

Your own code is only part of the picture. Popular crates format in their error paths too — a JSON parser that reports the line and column of a syntax error, a date library whose errors include the offending input, a collection whose Debug implementation is called by a logging macro. When twiggy paths leads into a dependency, there are three options. Check whether the crate has a feature that disables verbose errors; several parsing crates do. Replace it with a smaller crate designed for constrained targets, of which the no_std ecosystem has many. Or accept the cost knowingly, because some formatting is genuinely useful and the alternative would be worse error messages for users.

The point of the exercise is not to reach zero formatting at any price. It is to make sure every kilobyte of formatting in the shipped module is there because something useful depends on it, rather than because a convenient unwrap() three crates deep happened to pull it in.

Expected output

After the changes, the twiggy report is dominated by your own functions and the allocator:

 Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼──────────────────────────────────────
         11840 ┊    16.02% ┊ app::parser::parse_records
          6210 ┊     8.40% ┊ dlmalloc::dlmalloc::Dlmalloc::malloc
          4902 ┊     6.63% ┊ app::geometry::simplify

and twiggy top | grep core::fmt prints nothing.

Gotchas

  • Size barely changes after replacing unwraps. A dependency still formats. Use twiggy paths on the remaining core::fmt functions to find which crate.
  • panic_immediate_abort makes debugging painful. Keep it for release builds only; development builds should keep messages and console_error_panic_hook.
  • Integer overflow checks in release. If overflow-checks = true is set in the release profile, every arithmetic operation has a panic path. Leave it off unless you need it, and use checked arithmetic where overflow is possible.
  • Removing Debug breaks error conversions. Some error crates require Debug. Implement it manually with a short static string instead of deriving it.

Performance note

Removing panic paths also made the parser 4% faster on a benchmark, because bounds-checked indexing in a hot loop was replaced with iterator-based code the optimizer could vectorize. Size work on WebAssembly often improves speed as a side effect — less code means fewer instruction-cache misses and shorter compile times — but measure rather than assume.

Frequently Asked Questions

Does panic = "abort" not already remove this? It removes unwinding — landing pads and cleanup code — but not message formatting. Messages are built before the abort.

Is wee_alloc related? No, that is about the allocator; see replacing the default allocator to save bytes. Formatting and the allocator are usually the two largest non-application costs, and they are independent.

Can I keep messages in debug builds only? Yes — that is the usual setup. Development builds keep console_error_panic_hook and full messages; release builds abort immediately. Errors that callers need to handle should be Results in both.

Does this apply to no_std crates? A no_std crate still has core::fmt and panics. The same patterns apply, and no_std crates often end up the smallest because there is no standard library pulling formatting in through I/O.

← Back to Wasm Optimization Flags & Size Reduction