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-pack 0.13+ installed (cargo install wasm-pack@0.13.1)
  • [ ] The wasm32-unknown-unknown target 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) and wasm2wat (wabt) on PATH for 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-bindgen version mismatch. The build (or runtime) reports it 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 let wasm-pack manage it and avoid installing the CLI separately.

  • Bundler polyfills Node built-ins. Vite and Webpack may try to polyfill fs/path that 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.instantiateStreaming rejects a .wasm served as text/plain with Incorrect response MIME type. Expected 'application/wasm'. Configure the server to return Content-Type: application/wasm.

  • SharedArrayBuffer is not defined. A thread-enabled build (wasm-bindgen-rayon) needs cross-origin isolation. Without Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, instantiation throws this ReferenceError.

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.

← Back to Rust to Wasm Compilation Guide