Reducing Wasm Bundle Size with wasm-opt

This guide shows exactly how to run Binaryen’s wasm-opt to shrink an already-compiled .wasm binary — which passes to use, in what order, how to wire it into your build, and how to confirm the result is smaller and still valid.

wasm-opt is a Wasm-to-Wasm optimizer. The compiler that produced your binary (LLVM, via Rust or Emscripten) optimizes for a generic target; wasm-opt rewrites the finished module with WebAssembly-specific transforms — block merging, local coalescing, vacuuming dead code — and removes metadata sections the compiler left behind. On a typical release build it removes another 10–20% on top of compiler flags, before any transport compression.

Why a second optimizer helps at all comes down to scope. LLVM lowers your code while it still has many targets in mind and cannot always prove what the embedding host will call, so it leaves functions and control flow that are dead in practice but not provably dead to it. By the time wasm-opt runs, the binary is final: the full call graph is visible, every export is known, and Binaryen can delete any function unreachable from an export, flatten redundant blocks, and pack locals more tightly. It also understands Wasm-specific idioms — collapsing (i32.add x 0), removing unreachable code after a br, merging identical blocks — that a general backend does not specialize for. Think of it as the cleanup pass that only becomes possible once the whole module exists.

Prerequisites

  • [ ] wasm-opt from Binaryen ≥ 116 (wasm-opt --version)
  • [ ] wasm-validate and wasm-objdump from WABT ≥ 1.0.34 (wasm-validate --version)
  • [ ] A compiled input binary, e.g. pkg/app_bg.wasm from wasm-pack build --release
  • [ ] gzip and brotli to measure transfer size
  • [ ] ls -l / wc -c for byte-accurate measurement

Step-by-step procedure

1. Measure the input

You cannot claim a reduction without a baseline. Record the raw byte count and the compressed transfer size — the latter is what users actually download:

ls -l pkg/app_bg.wasm
# -rw-r--r-- 1 dev dev 198304 Jun 21 10:02 pkg/app_bg.wasm
gzip -9 -c pkg/app_bg.wasm | wc -c     # baseline gzip
brotli -q 11 -c pkg/app_bg.wasm | wc -c # baseline brotli

While you are here, look at what is large so you know whether wasm-opt is even the right tool. Dump the section headers and look for fat custom sections — name, producers, reloc.* — which are pure metadata that stripping removes, versus a large Code or Data section, which needs DCE or a source change respectively:

wasm-objdump -h pkg/app_bg.wasm | grep -E 'custom|Code|Data'

2. Run the canonical size pipeline

-Oz is Binaryen’s most aggressive size level; --converge re-runs the pipeline until size stops dropping; the --strip-* flags delete metadata custom sections:

wasm-opt pkg/app_bg.wasm \
  -Oz \
  --converge \
  --strip-debug \
  --strip-producers \
  -o pkg/app.opt.wasm

--strip-debug removes DWARF and the name custom section; --strip-producers removes the toolchain-version producers section. Keep an unstripped copy if you still need to symbolicate production stack traces.

A note on each flag’s job, because stacking them blindly is how reproducible builds break:

  • -Oz is Binaryen’s most aggressive size meta-pass — it favors code density over execution speed, which can slow a tight inner loop but minimizes bytes. Use -Os instead if a hot loop regresses.
  • --converge repeats the whole pipeline until the output stops shrinking. Without it you get a single pass; with it, passes that expose new opportunities for each other run to a fixed point.
  • --strip-debug is usually the largest single reduction on a release-with-debug build, because DWARF and the name section can be a quarter to a third of the file.

If you do not need to strip anything — for example you are profiling and want names preserved — drop the --strip-* flags and run -Oz --converge alone.

-Oz is a meta-flag that expands to a fixed pass list. Print it so your CI output is reproducible and you can spot a misbehaving pass:

wasm-opt pkg/app_bg.wasm --print-passes -Oz -o /dev/null

4. Enable post-MVP features if your module uses them

If the binary uses bulk memory, SIMD, or threads, wasm-opt will refuse to run until those features are explicitly allowed. Add only the ones you actually use:

wasm-opt pkg/app_bg.wasm -Oz --enable-bulk-memory --converge -o pkg/app.opt.wasm

Enable exactly the features your binary contains and no more. --all-features is convenient while diagnosing a rejection, but in a production pipeline it can let wasm-opt emit instructions your target runtime does not support, producing a binary that validates locally yet traps in an older engine. The common one is --enable-bulk-memory: modern Rust and Emscripten lower memcpy/memset to memory.copy/memory.fill, which both shrinks and speeds up byte-shuffling code, and every current browser supports it — but if you must target a runtime that does not, compile without it rather than stripping it back out afterward.

5. Wire it into the build

Make optimization a deterministic build step, not a manual afterthought. For Rust, configure wasm-pack to invoke wasm-opt for you in Cargo.toml:

[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz", "--strip-debug", "--converge"]

For a Makefile-driven C/C++ project, run it as an explicit post-link target:

.PHONY: optimize-wasm
optimize-wasm:
	wasm-opt dist/module.wasm -Oz --strip-debug --converge -o dist/module.opt.wasm
	mv dist/module.opt.wasm dist/module.wasm
	@wc -c dist/module.wasm

For Emscripten specifically, the toolchain already runs wasm-opt internally at -O2 and above, so a hand-rolled second pass is usually redundant. Override its built-in flags instead of adding your own stage:

export EMCC_WASM_OPT_FLAGS="-Oz --strip-debug --converge"
emcc main.c -Oz -s WASM=1 -o dist/module.js

Whichever path you choose, the goal is the same: make optimization a deterministic output of the build so the artifact you test in CI is byte-for-byte the artifact you ship, and so a cache key derived from the file hash stays stable across rebuilds of unchanged source.

Expected output

Compare the input and output byte-for-byte, then compress to see the on-the-wire size:

ls -l pkg/app_bg.wasm pkg/app.opt.wasm
# -rw-r--r-- 1 dev dev 198304 pkg/app_bg.wasm     raw
# -rw-r--r-- 1 dev dev 128784 pkg/app.opt.wasm    optimized + stripped → ~35% smaller

gzip -9 -c pkg/app.opt.wasm | wc -c     # 49870  gzip
brotli -q 11 -c pkg/app.opt.wasm | wc -c # 41210  brotli, the bytes that ship

The metadata removal is the largest single contributor here — the name and DWARF sections in a release-with-debug build routinely account for 25–40% of the file.

To see where the rest of the reduction came from, dump the section headers before and after and confirm the name and .debug_* custom sections are gone while Code and Data shrank:

wasm-objdump -h pkg/app_bg.wasm  | grep -E 'custom|Code|Data'
# custom  "name"   size=41280
# Code             size=131204
# Data             size=18944

wasm-objdump -h pkg/app.opt.wasm | grep -E 'custom|Code|Data'
# Code             size=110016     ← DCE + peephole shrank code
# Data             size=18944      ← untouched: data passes don't move it

The Code section shrank from the DCE and peephole passes; the name custom section disappeared entirely from the strip; and Data is unchanged, a reminder that wasm-opt optimizes instructions, not your static byte arrays.

Gotchas

  • Fatal: error in validating input — the binary uses a feature wasm-opt was not told about. The message often names it (e.g. bulk memory operations require bulk memory to be enabled). Add the matching --enable-bulk-memory / --enable-simd / --enable-threads flag and re-run.
  • A JavaScript-called function disappears. -Oz runs dead-code elimination and removes any function not reachable from an export. If JS calls it, it must be exported in source (#[wasm_bindgen] or EXPORTED_FUNCTIONS=['_fn']). Diff the export tables to catch it:
    wasm-objdump -x pkg/app_bg.wasm | grep '^Export' > pre.txt
    wasm-objdump -x pkg/app.opt.wasm | grep '^Export' > post.txt
    diff pre.txt post.txt
  • wasm-opt: command not found after wasm-pack installs its own copy. wasm-pack downloads a pinned wasm-opt into its cache and may not be on PATH. Install Binaryen system-wide (apt install binaryen, brew install binaryen) for direct CLI use.
  • Memory limits silently changed. Aggressive passes can normalize the memory section. Confirm the declared maximum survived if you depend on it:
    wasm2wat pkg/app.opt.wasm | grep '(memory'
    # (memory (;0;) 17 256)   → initial 17 pages, max 256 — as intended
  • The output validates but traps at runtime in an older engine. You enabled a feature with --enable-* (or --all-features) that your raw binary did not need, and wasm-opt used it. Build with only the features your target supports rather than enabling them at the optimizer.

Always finish with the official validator, which catches a malformed rewrite before it reaches a browser:

wasm-validate pkg/app.opt.wasm && echo 'Valid' || echo 'Invalid'

Performance note

Size and parse latency are linked but not identical. The 35% byte reduction above cut Chrome’s Parse/Compile time (Network → Timing) from roughly 48 ms to 31 ms on a mid-tier laptop, because the engine has less code to baseline-compile. Confirm the figure in the browser rather than assuming it:

const t0 = performance.now();
const { instance } = await WebAssembly.instantiateStreaming(fetch("/pkg/app.opt.wasm"));
console.log(`parse + compile + instantiate: ${(performance.now() - t0).toFixed(1)} ms`);

But note -Oz optimizes for density, so a hot numeric loop inside the module may run 5–15% slower than the same code built with -O3, because -Oz disables the loop unrolling and some of the inlining that make tight loops fast. The right call is per-module: ship -Oz for glue, UI, and cold-path code where bytes matter and execution time does not, and reserve -O3/-Os for the compute kernel where a measurable speed regression would outweigh the few kilobytes saved. Benchmark the specific function before committing — the Wasm performance benchmarking harness exists precisely so this is a measurement, not a guess.

Frequently Asked Questions

Does wasm-opt -Oz strip exports automatically? No. It removes only functions unreachable from an export. Every export you declare is preserved, even if nothing internal calls it. To shrink the export surface, remove the export in your source, then let DCE delete the now-unreachable code.

Is --converge worth the extra build time? For size-critical builds, yes — it re-runs the pipeline until output stabilizes, usually 2–3 passes, recovering a further 3–7%. For fast iterative dev builds, drop it and run a single -Oz pass.

Can I run wasm-opt on an Emscripten binary? Yes, though Emscripten already invokes wasm-opt internally at -O2 and above. Override its flags with export EMCC_WASM_OPT_FLAGS="-Oz --strip-debug --converge" rather than running a redundant second pass by hand.

My optimized binary is larger than the input — what went wrong? You almost certainly re-ran wasm-opt on an already-processed file, passed a --debug flag, or enabled a feature that forced a more conservative lowering. Always start from the raw compiler output, and compare against the byte count you recorded in step 1 to catch a regression immediately.

← Back to Wasm Optimization Flags & Size Reduction