Best Practices for wasm-pack Configuration
This guide shows how to configure wasm-pack for a production Rust-to-WebAssembly build: the
Cargo.toml metadata that controls wasm-opt, the right --target for your consumer, the
bundler settings that prevent runtime instantiation errors, and the CI setup that makes builds
reproducible.
Prerequisites
- [ ]
wasm-pack0.13+ installed (cargo install wasm-pack@0.13.1) - [ ] The
wasm32-unknown-unknowntarget added (rustup target add wasm32-unknown-unknown) - [ ] A crate already compiling — see the Rust to Wasm compilation guide for the baseline pipeline
- [ ]
wasm-opt(Binaryen) andwasm2wat(wabt) onPATHfor verification
Procedure
1. Declare the crate type
wasm-pack needs a cdylib to emit a standalone module with a flat export table. Without it
the compiler produces a normal Rust library that no Wasm runtime can instantiate. Keep rlib
beside it so cargo test still runs your logic natively.
# Cargo.toml
[lib]
crate-type = ["cdylib", "rlib"]
2. Pin versions to stop ABI drift
A wasm-bindgen crate version that disagrees with the wasm-bindgen-cli wasm-pack invokes
is the single most common silent break. Pin both, and commit Cargo.lock.
cargo install wasm-pack@0.13.1
rustup target add wasm32-unknown-unknown
# Cargo.toml — pin the crate so the lockfile is authoritative
[dependencies]
wasm-bindgen = "=0.2.92"
3. Run a dev build first
Always surface linker and macro errors with an unoptimized build before tuning flags. A --dev
build skips wasm-opt, so it is fast and keeps symbol names readable in DevTools.
wasm-pack build --dev --target bundler
4. Select the target for your consumer
The --target flag decides how the generated .js loads the binary:
wasm-pack build --release --target bundler # Webpack 5+, Rollup, esbuild
wasm-pack build --release --target web # bundler-free ESM, Vite, CDN
wasm-pack build --release --target nodejs # CommonJS, fs.readFileSync, synchronous init
Mismatched targets fail at runtime, not build time — --target web glue loaded by Node throws
SyntaxError: Cannot use import statement outside a module, and nodejs glue in the browser
throws require is not defined. The practical rule: bundler when a build tool will resolve the
.wasm import for you, web when the browser fetches the .wasm itself (no build step or a Vite
dev server), and nodejs for server-side or CLI tools that need synchronous, fs-based loading.
There is also --target no-modules for the rare case of a global <script> with no module
system at all. The binary is byte-for-byte identical across all of them; only the glue differs,
so switching consumers never requires recompiling your Rust.
5. Control wasm-opt from profile metadata
wasm-pack runs wasm-opt automatically on --release, defaulting to -O (speed-leaning).
Override it in Cargo.toml to enforce a size budget. This metadata block is the canonical place
to set Binaryen flags — it travels with the crate and applies in CI without extra scripting.
# Cargo.toml
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz", "--enable-bulk-memory"]
--enable-bulk-memory lets the optimizer use memory.copy and memory.fill instead of byte
loops for large Vec<u8> transfers, shrinking glue and speeding up buffer moves. The full
flag catalogue is in
reducing Wasm bundle size with wasm-opt.
A subtle but important point about precedence: the wasm-opt array in this metadata block
replaces wasm-pack’s defaults rather than appending to them. If you specify only ["-Oz"]
you lose the feature-enabling flags wasm-pack would otherwise add, so list every flag you want,
including any --enable-* features your binary relies on. Two flags worth knowing beyond size:
--enable-reference-types permits externref, which lets wasm-bindgen pass JavaScript objects
across the boundary as opaque handles instead of serializing them (needs wasm-bindgen 0.2.84+),
and --enable-simd unlocks 128-bit SIMD if your Rust uses core::arch::wasm32 intrinsics. Enable
a feature only when you actually use it — each one narrows the set of runtimes that can load the
module.
6. Disable wasm-opt while debugging
wasm-opt rewrites instruction layout and strips debug info, scrambling DevTools line numbers.
Turn it off during hot-reload cycles:
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
Expected output and verification
After a release build, confirm the package contents and that dead-code elimination kept your exports:
ls pkg/
# wasm_core.js wasm_core.d.ts wasm_core_bg.wasm wasm_core_bg.wasm.d.ts package.json
wasm2wat pkg/wasm_core_bg.wasm | grep '(export '
(export "memory" (memory 0))
(export "greet" (func 12))
(export "__wbindgen_malloc" (func 30))
If an export you expected is absent it was stripped as unreachable — check it is pub and
carries #[wasm_bindgen]. To enforce a size budget in the same step:
SIZE=$(wc -c < pkg/wasm_core_bg.wasm)
[ "$SIZE" -gt 512000 ] && { echo "FAIL: $SIZE bytes over 500 KB budget"; exit 1; }
Gotchas
-
wasm-bindgenversion mismatch. The build (or runtime) reportsit looks like the Rust project used to create this Wasm file was linked against a different version of wasm-bindgen than this binary. Reinstall the CLI to match the crate:cargo install wasm-bindgen-cli --version 0.2.92, or letwasm-packmanage it and avoid installing the CLI separately. -
Bundler polyfills Node built-ins. Vite and Webpack may try to polyfill
fs/paththat the glue references conditionally, producing resolution warnings. Disable them explicitly:// vite.config.js export default { resolve: { alias: { fs: false, path: false, os: false } } }; -
Wrong MIME type.
WebAssembly.instantiateStreamingrejects a.wasmserved astext/plainwithIncorrect response MIME type. Expected 'application/wasm'. Configure the server to returnContent-Type: application/wasm. -
SharedArrayBuffer is not defined. A thread-enabled build (wasm-bindgen-rayon) needs cross-origin isolation. WithoutCross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp, instantiation throws thisReferenceError.
7. Trim the dependency surface
Most unexpected binary weight comes from dependencies, not your own code. Audit which features
are active and disable the ones the Wasm path does not need — a crate that defaults to a native
backend often has a lighter default-features = false profile. Build with --no-default-features
to confirm a clean baseline before re-adding only what you use:
wasm-pack build --release -- --no-default-features
twiggy top -n 15 pkg/wasm_core_bg.wasm
twiggy top ranks the heaviest items in the binary, which is the fastest way to spot a
dependency or formatting routine that is costing you more than it is worth.
Performance note
The wasm-opt choice dominates final size more than any Rust flag. On a typical mid-size crate,
moving the metadata wasm-opt from the default -O to -Oz shaves a further 20–30% off the
already-compiled binary — a 110 KB module commonly drops to around 80 KB — at a cost of a few
percent compute throughput. Always measure the post-wasm-opt size, not cargo’s output, when
you set budgets, because cargo’s intermediate .wasm is the input to wasm-opt, not what you
ship. The second-largest lever is codegen-units = 1, which lets LTO work across the whole crate;
it can remove another 5–10% but multiplies clean-build time, so keep it in the release profile
only and leave development builds parallelized.
CI considerations
The configuration that makes a local build correct is not automatically the configuration that
makes a CI build reproducible. Two machines can run the same wasm-pack build and emit
different bytes if the toolchain, wasm-pack version, or wasm-opt version differ. Lock all
three: rust-toolchain.toml pins the compiler, a downloaded release binary pins wasm-pack
(prefer this over cargo install wasm-pack in CI, which can resolve a newer patch), and your
profile metadata pins the wasm-opt flags. Cache ~/.cargo/registry, ~/.cargo/git, and
target/wasm32-unknown-unknown keyed on Cargo.lock to cut compile time substantially:
# GitHub Actions
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target/wasm32-unknown-unknown
key: wasm-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
With everything pinned, sha256sum pkg/wasm_core_bg.wasm should be identical across runners. A
divergence almost always points to an unpinned wasm-opt or stray RUSTFLAGS in one environment.
The end-to-end pipeline, including artifact upload and deploy, is covered in
setting up CI/CD for Rust Wasm projects.
Frequently Asked Questions
Should I commit the pkg/ directory?
No. Generate it in CI and cache the artifact. Committing it invites stale binaries that disagree
with the source, and it bloats the repository with content-addressed build output.
Where do wasm-opt flags actually belong — CLI or Cargo.toml?
Put them in [package.metadata.wasm-pack.profile.release] so wasm-pack applies them every
time, including CI. Only run wasm-opt on the CLI for one-off experiments or an extra pass on
top of wasm-pack’s output.
How do I get reproducible builds across macOS and Linux runners?
Pin the toolchain with rust-toolchain.toml, pin wasm-pack to an exact version (download a
release binary rather than cargo install in CI), and commit Cargo.lock. Then
sha256sum pkg/wasm_core_bg.wasm should match across runners; if it diverges, audit RUSTFLAGS
and the wasm-opt version.
Related
- Reducing Wasm bundle size with wasm-opt — the full Binaryen flag reference.
- Choosing between Emscripten, wasm-pack, and TinyGo — picking the right toolchain for your language.
- Setting up CI/CD for Rust Wasm projects — caching and reproducible pipelines.
← Back to Rust to Wasm Compilation Guide