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.

Shallow size and retained size answer different questions A function's shallow size is its own code. Its retained size includes everything reachable only through it, so removing that function would remove all of it. A small function with a large retained size is usually the most valuable thing to delete. format_report — 380 B core::fmt — 18 KB float formatting — 9 KB padding logic — 4 KB Shallow size of format_report: 380 bytes. Retained size: about 31 KB, because nothing else references any of it. Deleting one small function therefore removes 31 KB — which is why retained size is the column to sort by.

Prerequisites

  • [ ] twiggy installed (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-opt already 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.

From ranking to action Formatting machinery retained by one export can be removed by returning data instead of text. Monomorphised generics can be collapsed by taking a slice rather than a generic iterator. The allocator can be swapped for a smaller one. Each has a different cost to implement. formatting retained by one export — 24% return structured data, format in JS cheap change, large win generic instantiated 6 times — 9% take &[T] instead of impl Iterator moderate change, moderate win the allocator — 5% swap for a smaller one, or an arena risky change, small win — do it last Sorting by win-per-effort rather than by size alone is what makes the output a plan instead of a list.

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.

Where analysis belongs in the pipeline Run the cheap flag-level optimisations first so the analysis reflects what the compiler could not remove. Then attribute the remainder, then change source. Stripping happens last, because the analysis needs the name section. 1 · flags + wasm-opt cheap, always first 2 · twiggy needs the name section 3 · change source ranked by retained size 4 · strip last, for the shipped file Stripping before analysing is the mistake that wastes the most time — the tool then has nothing but function indices to report.

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.

← Back to Wasm Optimization Flags & Size Reduction