Optimising for Production
Writing correct WebAssembly is only half the job. In production you want the binary to be as small as possible (faster downloads, warmer caches) and to start executing as quickly as possible (lower time-to-interactive). This lesson assembles the complete playbook.
Step 1 — compile with maximum size optimisation
Section titled “Step 1 — compile with maximum size optimisation”Whatever toolchain you use, enable the highest size-optimisation flag:
# wasm-opt (standalone or post-compile)wasm-opt -Oz input.wasm -o output.wasm
# Rust via wasm-packwasm-pack build --release # wasm-opt -Oz runs automatically
# Emscriptenemcc -Oz source.c -o output.wasm
# TinyGotinygo build -opt=z -target wasm -o output.wasm ./main.goStep 2 — strip debug sections
Section titled “Step 2 — strip debug sections”Debug information (DWARF, name sections, producer metadata) can easily double the binary size. Strip it for production.
# Strip all custom sections with wasm-optwasm-opt -Oz --strip-debug --strip-producers output.wasm -o output.stripped.wasm
# Or with wasm-toolswasm-tools strip output.wasm -o output.stripped.wasm
# Measure the effectwc -c output.wasm output.stripped.wasmKeep an unstripped copy on your build server so you can symbolicate crash reports.
Step 3 — reduce the allocator (Rust)
Section titled “Step 3 — reduce the allocator (Rust)”For Rust targets, the default std allocator (dlmalloc via wasm-bindgen) adds roughly 10 KB. Switching to wee_alloc or the newer lol_alloc can save several kilobytes:
# Cargo.toml[dependencies]wee_alloc = "0.4"
# lib.rs — replace the global allocator# Build (wasm-pack handles the rest)wasm-pack build --release --target webFor modules with no dynamic allocation at all, compile with #![no_std] and eliminate the allocator entirely.
Step 4 — lazy and streaming instantiation
Section titled “Step 4 — lazy and streaming instantiation”Browsers can start compiling a .wasm file while it is still downloading if you use WebAssembly.instantiateStreaming. This is always faster than fetching first and compiling second.
# Serve with the correct MIME typeIn JavaScript:
// Streaming (recommended)const { instance } = await WebAssembly.instantiateStreaming( fetch('/module.wasm'), importObject);
// Non-streaming (fallback for servers without correct MIME type)const bytes = await fetch('/module.wasm').then(r => r.arrayBuffer());const { instance } = await WebAssembly.instantiate(bytes, importObject);For large modules on repeat visits, cache the compiled WebAssembly.Module in IndexedDB to skip compilation entirely on the next load.
Step 5 — measure everything
Section titled “Step 5 — measure everything”Optimisation without measurement is guesswork. Keep these three metrics in your CI pipeline:
# 1. Binary size (raw and gzip)wc -c output.wasmgzip -c output.wasm | wc -c
# 2. Size breakdown by functiontwiggy top output.wasm | head -20
# 3. Validate the optimised binary has not been brokenwasm-validate output.wasmwasm-tools validate output.wasmA regression in any of these three metrics after a dependency update is a signal to investigate before shipping.
Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
wasm-opt -Oz (most aggressive optimization) | Cuts binary size and boosts speed the most, across every step of this playbook | Longer build times, and debugging gets harder because the code is reordered far from the original source |
Keeping debug info / --debug-names | Lets you symbolicate crash reports and debug production issues | Increases the size of the binary users actually download — working against this lesson’s goal |
Common mistakes
Section titled “Common mistakes”- Shipping the
.wasmstraight from the compiler without ever runningwasm-opt, missing the size/speed reduction that step 1 gets almost for free - Jumping straight to
-Ozor other aggressive flags without profiling first to see what’s actually big or slow, fixing the wrong thing - Stripping debug info from every build, including ones actually used for debugging, so a production crash is hard to trace because the stack trace has no names
💡 Real-world example
Emscripten already runs Binaryen’s
wasm-optautomatically at-O2/-O3(Emscripten optimization docs) — this playbook’s five steps are what to run explicitly in CI for toolchains that don’t do that for you, or when hand-optimizing a WAT-authored or third-party binary.