Analyzing Wasm Size with twiggy
This guide uses twiggy to find out which functions actually occupy a WebAssembly binary, why they
are retained, and which of them a source change could remove — turning “the module is too big” into a
ranked list of specific things to do.
Size work without attribution is guesswork. Compiler flags and wasm-opt passes are worth running
first because they are cheap, but once they are exhausted the remaining bytes belong to identifiable
code, and the only way to know which code is to measure. twiggy reads the binary and its name section
and reports exactly that.
Prerequisites
- [ ]
twiggyinstalled (cargo install twiggy) - [ ] A binary that still has its name section — do not strip before analysing
- [ ] The release build, not a debug one; their profiles differ completely
- [ ]
wasm-optalready run, so you are looking at what optimisation could not remove
Step-by-step procedure
1. Build with names intact
cargo build --release --target wasm32-unknown-unknown
wasm-opt target/wasm32-unknown-unknown/release/app.wasm -Oz -o app.opt.wasm # no --strip-debug yet
Stripping is the last step of a release pipeline, not the first step of an investigation. Without the
name section twiggy can only report opaque function indices.
2. Rank by shallow size
twiggy top -n 20 app.opt.wasm
This is the “what is big” view: the twenty functions occupying the most bytes in their own right. Standard-library formatting, panic machinery and any large generated code usually appear here.
3. Rank by what removal would save
twiggy top --retained -n 20 app.opt.wasm
Retained size is the more actionable column. A function with a small shallow size and a large retained size is a single deletion that takes a subtree with it.
4. Ask why something is present
twiggy paths app.opt.wasm 'core::fmt::float::float_to_decimal_common'
paths walks backwards from a symbol to the exports that keep it alive. That answers the question
“why is float formatting in my numeric kernel” — usually with a println! or a Display
implementation you forgot about.
5. Look for monomorphisation bloat
twiggy monos -n 10 app.opt.wasm
Generic functions instantiated for many types produce near-identical copies. monos shows how many
bytes each generic costs in total and how much a single shared implementation would save.
6. Act, rebuild, re-measure
Expected output
$ twiggy top --retained -n 8 app.opt.wasm
Retained Bytes │ Retained % │ Item
────────────────┼────────────┼──────────────────────────────────────────────
31 204 │ 24.2 % │ app::format_report::h1c2
18 940 │ 14.7 % │ core::fmt::write::h9ab
12 118 │ 9.4 % │ app::parse::Parser::run::h55e
6 402 │ 5.0 % │ dlmalloc::Dlmalloc::malloc::hf01
4 118 │ 3.2 % │ app::decode::table_init::h77c
$ twiggy paths app.opt.wasm 'core::fmt::write::h9ab'
core::fmt::write
⬑ app::format_report
⬑ export "generate_report"
That output is a work order. The report formatter and everything under it is a quarter of the binary, and it exists because one export calls it — so returning structured data and formatting in JavaScript removes 24% of the module in a single change.
Gotchas
Everything is called wasm-function[142]. The name section was stripped. Re-run on the unstripped
artifact; strip afterwards for the shipped file.
The numbers do not match the file size. twiggy reports the code section and data; custom sections
and the binary’s structural overhead are separate. Compare with wasm-objdump -h for the whole picture.
A large item you cannot find in your source. It is from a dependency or the standard library.
twiggy paths still tells you which of your functions retains it, which is the actionable end.
Analysing a debug build. Debug binaries are dominated by information a release build removes; the ranking is not transferable. Always profile the release artifact.
Removing a function does not shrink the binary as predicted. Something else also referenced part of the subtree, so the retained estimate was optimistic. Re-run after each change rather than adding up predictions.
Performance note
Size and startup time move together, since compile time scales roughly with code size — so a 24% reduction is also a 20% or so reduction in compile time, and that is the phase most visible on a slow device. Measure the compressed size after each change, though, because code that compresses well may shrink the raw binary far more than the bytes that actually travel.
The one caution is that not all size reductions are free. Swapping to a smaller allocator can cost throughput; removing a specialised generic in favour of a dynamic one adds indirection to a hot path. Rank by win-per-effort, and check throughput after any change that touches code in a loop — the benchmarking harness exists so that check takes a minute.
The four things that are usually large
Across Rust and C++ modules the same handful of items dominate, and recognising them in the output saves a lot of investigation.
Formatting machinery. core::fmt and the float-to-decimal code it pulls in are frequently the
largest single subtree in a Rust module, and they arrive through a single println!, format! or
Display implementation. Removing the last such call removes tens of kilobytes at once, which is why
returning structured data rather than formatted text is the highest-value change available for many
modules.
Panic and unwinding support. Panic messages, their formatting, and the unwinding tables are pure
overhead in a sandboxed module that cannot catch a panic anyway. panic = "abort" in the release
profile removes most of it, and panic_immediate_abort on a nightly toolchain removes nearly all of
what remains.
Monomorphised generics. A generic function instantiated for six types produces six near-identical
copies. twiggy monos quantifies it, and the fix is to make the generic a thin wrapper over a
non-generic implementation — take &[T] rather than impl Iterator, or convert once at the boundary
and call a concrete function.
The allocator. dlmalloc and its equivalents are a few kilobytes that most modules genuinely need.
Replacing it with something smaller is possible and rarely the best next move — it is a small fraction
of the binary and a change with real throughput consequences.
Tracking size in CI
Because twiggy top output is deterministic for a given binary, it diffs cleanly between builds, which
turns size into something a reviewer sees rather than something discovered months later:
brotli -q 11 -c dist/kernel.wasm | wc -c > size.txt
twiggy top --retained -n 15 dist/kernel.wasm > profile.txt
Comparing size.txt against the base branch and failing on a regression beyond a tolerance is the
enforcement; attaching profile.txt to the build is the explanation. When the number does move, the
diff of the two profiles usually names the cause immediately — a new dependency appearing in the top
fifteen, or a subtree that grew because a generic gained another instantiation.
That combination — a hard number that fails the build and a ranked profile that explains it — is what keeps a module’s size from drifting upward one small change at a time, which is how nearly every oversized binary got that way.
Frequently Asked Questions
Does twiggy work on Emscripten output?
Yes, if the name section survived. Emscripten strips at higher optimisation levels, so build once with
names for the analysis and once without for shipping.
How does this relate to wasm-opt?
Complementary. wasm-opt removes what it can prove is unreachable; twiggy shows you what is
reachable but should not be, which only a source change can fix.
Can I run it in CI?
Yes — twiggy top --retained output is stable enough to diff between builds, which turns a size
regression into a reviewable change rather than a surprise months later.
Why is the sum of the top items smaller than the file? Because the ranking shows the largest items, not all of them, and a module’s size is often spread across a long tail of small functions rather than concentrated in a few large ones. If the top twenty account for only a third of the binary, that is itself useful information — it means there is no single change that will help much, and the productive move is a structural one such as removing a dependency rather than optimising individual functions.
Does the data section show up? Yes, as data segments rather than as functions. A module embedding a large lookup table, a font, or compressed assets will show that weight there, and no amount of code optimisation touches it. Moving such data out of the binary and fetching it separately is usually the right answer, since it also lets the browser cache the two independently.
Related
- Wasm optimization flags & size reduction — the flags to exhaust before reaching for this.
- Reducing Wasm bundle size with wasm-opt — the optimiser pass that runs first.
- Rust to Wasm compilation guide — the release profile settings behind most of these numbers.
- Reducing Wasm cold-start latency — what a smaller binary buys at load time.
← Back to Wasm Optimization Flags & Size Reduction