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 thenamesection 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.
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)?;
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.
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 pathson the remainingcore::fmtfunctions to find which crate. panic_immediate_abortmakes debugging painful. Keep it for release builds only; development builds should keep messages andconsole_error_panic_hook.- Integer overflow checks in release. If
overflow-checks = trueis 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.
Related
- Handling panics in Rust Wasm — what happens when a panic does occur.
- Shrinking Rust Wasm with Cargo profiles — the profile settings that come first.
- Logging from Rust Wasm to the browser console — logging without keeping formatting in release.
- Propagating Rust results to JavaScript — turning error enums into JavaScript errors.
← Back to Wasm Optimization Flags & Size Reduction