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-opt from Binaryen, and twiggy for 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.

What each step removes Starting from a default release build, aborting on panic, optimising for size, enabling link-time optimisation, stripping debug information and running wasm-opt each remove a portion of the module. default release 148 kB + panic=abort 116 kB + opt-level=z 89 kB + lto + 1 cgu 62 kB + strip + wasm-opt 41 kB Three and a half times smaller from configuration alone, before touching a line of code.

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.

What the bytes are Before size work, formatting machinery and unwinding tables dominate a small module. After the profile changes and removing formatting from the module, the application's own code is the majority. before — 148 kB core::fmt unwinding dependencies yours after — 41 kB deps your code the module is now mostly the thing it exists to do That inversion is the goal: when your own code dominates the binary, further size work has run out of easy wins.

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.

One change at a time, measured Each setting is applied on top of the previous one and the binary measured after each, so the contribution of every change is visible rather than guessed. release, defaults 412 KB opt-level = "z" 304 KB -108 KB lto = true, codegen-units = 1 238 KB -66 KB panic = "abort" + wasm-opt -Oz 181 KB -57 KB Measure compressed size too — removing uncompressible code shrinks the raw binary and barely moves the download. opt-level z can cost throughput on hot numeric loops; if the module is compute-bound, measure speed after each win.

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" with catch_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-opt skipped. wasm-pack runs 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.

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