Shrinking Rust Wasm with Cargo Profiles
This guide answers one task: reduce the size of a Rust WebAssembly module using build configuration and a small number of code changes, measuring each step so you know what actually helped.
Prerequisites
- [ ] A Rust crate that builds for
wasm32-unknown-unknown. - [ ]
wasm-optfrom Binaryen, andtwiggyfor attribution. - [ ]
brotli, because compressed size is the number that matters. - [ ] A baseline measurement before changing anything.
Measure first
Every recommendation below has a cost, and applying all of them blindly to a module that was already small wastes effort. Start with a number.
cargo build --release --target wasm32-unknown-unknown
W=target/wasm32-unknown-unknown/release/engine.wasm
printf "raw %d compressed %d\n" "$(stat -c%s $W)" "$(brotli -q 11 -c $W | wc -c)"
# raw 412688 compressed 148204
That is a typical starting point for a crate with a few dependencies and no size configuration: 148 kB compressed for something whose logic might be a few kilobytes. The rest is machinery, and most of it can go.
The profile
Five settings do most of the work, and they belong in a release profile in Cargo.toml.
[profile.release]
opt-level = "z" # optimise for size rather than speed
lto = true # link-time optimisation across crates
codegen-units = 1 # one unit, so LTO sees everything
panic = "abort" # no unwinding machinery
strip = true # no symbol or debug sections
panic = "abort" is usually the single largest win. Unwinding requires landing pads throughout the
generated code plus tables describing them, and a WebAssembly module cannot meaningfully unwind anyway —
a panic becomes a trap either way. The cost is that catch_unwind stops working, which almost no
WebAssembly module uses.
opt-level = "z" optimises aggressively for size, including disabling loop vectorisation. For interface
and glue code that is free; for a numeric kernel it can cost real speed, which is why "s" — a gentler
size optimisation — is worth testing if the module does heavy computation.
lto = true with codegen-units = 1 lets the optimiser see the whole program and remove far more dead
code. It makes builds noticeably slower, which is why it belongs in the release profile only.
Two profiles, two purposes
A single release profile has to serve both a size-optimised web build and a fast native build, and those want different settings. Cargo supports custom profiles, which lets each have what it needs.
[profile.release]
opt-level = 3 # native: speed
lto = "thin"
codegen-units = 16
[profile.web]
inherits = "release"
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
cargo build --profile web --target wasm32-unknown-unknown
cargo build --release # native, unchanged
That separation matters more than it looks. A team that applies size settings globally makes their native tests and benchmarks slower and less representative; a team that applies speed settings globally ships a module three times larger than it needs to be. Naming the profile after its purpose also makes the intent visible in the build command, which is worth something when someone else runs it.
If the project ships both a baseline and a SIMD build, a third profile inheriting from web with the
SIMD target feature keeps all three configurations in one place rather than spread across shell scripts.
What remains, and where it came from
After the profile, use twiggy to see what is actually in the binary. The answer is frequently
surprising.
twiggy top -n 15 target/wasm32-unknown-unknown/release/engine.wasm
Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼────────────────────────────────────────
14208 │ 22.9% │ core::fmt::Formatter::pad
8104 │ 13.1% │ <&T as core::fmt::Display>::fmt
5312 │ 8.6% │ core::str::slice_error_fail
4096 │ 6.6% │ data[0]
2944 │ 4.7% │ engine::process
Formatting machinery at the top is the normal result, and it is there because something in the crate — or
in a dependency — formats a string. A single format! in an error path pulls in the whole of
core::fmt, which for a small module is larger than everything else combined.
Removing it means not formatting inside the module. Return an error code or a static string, and let JavaScript produce the message:
// before: pulls in core::fmt
return Err(JsValue::from_str(&format!("bad length {len}, max {MAX}")));
// after: a code the caller formats
return Err(ErrorCode::LengthTooLarge as u32);
slice_error_fail and its relatives come from panicking index operations. Using get() and handling
None removes the panic path and its message machinery together.
The final pass
wasm-opt runs Binaryen’s own optimisations over the finished binary and typically finds another 10–20%
that the compiler did not.
wasm-opt -Oz --strip-debug --strip-producers \
-o dist/engine.wasm target/wasm32-unknown-unknown/release/engine.wasm
--strip-producers removes the metadata section naming the toolchain, which is small but free.
--strip-debug removes the name section — useful in development for readable stack traces and pure cost
in production, which is a reason to produce two builds rather than one.
For a wasm-pack project, the same pass is configured in the manifest so it runs as part of the build:
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz", "--strip-debug", "--strip-producers"]
Dependencies, weighed honestly
After configuration and formatting, dependencies are what is left. A crate that is convenient on a server can be disproportionate in a browser module.
The usual heavy contributors are serialisation frameworks with derive macros, error libraries that build
formatted messages, anything pulling in regex, and crates with large lookup tables. Their cost is
measurable in a minute:
brotli -q 11 -c dist/engine.wasm | wc -c # before
cargo add serde_json && cargo build --release --target wasm32-unknown-unknown
brotli -q 11 -c dist/engine.wasm | wc -c # after
Many crates offer a default-features = false configuration that removes most of the weight, and using it
is the first thing to try before replacing a dependency. Where a crate is genuinely needed and genuinely
large, that is a decision to make explicitly rather than by accident.
Expected output
The full sequence, with the number after each step:
default release 148204
+ panic=abort 116480
+ opt-level=z 88832
+ lto, codegen-units=1 62208
+ strip, wasm-opt -Oz 41216
+ removed format! from the module 28160
Six changes, five of them configuration, and the module is down to 19% of where it started. The last line is the only one requiring code changes, and it is often the largest single step.
Gotchas
opt-level = "z"on a numeric kernel. Can cost 30–50% of throughput. Measure, and consider"s".- LTO in the development profile. Makes every build slow for no benefit while iterating.
panic = "abort"withcatch_unwind. The latter stops working; almost nothing in a module uses it.- Stripping debug information in development. Removes readable stack traces exactly when you need them.
- Measuring raw size only. Compression ratios differ between changes; some steps look larger than they are.
wasm-optskipped.wasm-packruns it by default; a hand-rolled build often does not, leaving 10–20% on the table.
Performance note
For the module above, opt-level = "z" cost about 12% throughput on its numeric path while removing 24%
of the size — a good trade for glue-heavy code and a poor one for a kernel. Building the same crate with
opt-level = 3 and everything else unchanged produced 52 kB compressed and the full speed, which for a
compute module is the right corner of the trade. Choose per module rather than per project.
Frequently Asked Questions
Is no_std worth it?
For a small, self-contained kernel, yes — it removes the standard library’s machinery entirely and can
take a module into single-digit kilobytes. For anything using collections, strings or error types it is a
significant rewrite for a diminishing return.
Does the glue JavaScript matter too?
It does, and wasm-pack output is a few kilobytes that minify well. Include it in whatever number you
track, since the user downloads both.
What about wee_alloc?
It was the standard advice and is no longer maintained. The default allocator is larger but correct; for a
module that can use a fixed arena, replacing the allocator entirely is both smaller and faster than any
general-purpose alternative.
How small can a Rust module realistically get?
A module with no standard library allocation, no formatting and a handful of exported functions reaches
single-digit kilobytes; one using collections, strings and a serialisation crate settles in the tens. If
your figure is above a hundred kilobytes compressed after this work, the remaining weight is almost
certainly a specific dependency, and twiggy will name it.
Does any of this affect correctness?
panic = "abort" changes behaviour if you rely on unwinding, and aggressive optimisation can expose
latent undefined behaviour in unsafe code. Run the test suite against the release profile, not only the
development one.
Related
- Analyzing Wasm size with twiggy — attribution in depth.
- Reducing Wasm bundle size with wasm-opt — the final pass.
- Catching size regressions in CI — keeping the gains.
Record the final number as a baseline and gate on it, or the work on this page will need repeating in six months.
← Back to Rust to Wasm Compilation Guide