Building Rust Wasm Without wasm-pack

This page answers one task: produce the same browser-ready package wasm-pack build gives you, but by running the individual tools yourself — so you can see, change and script every step.

Prerequisites

  • [ ] Rust with rustup target add wasm32-unknown-unknown.
  • [ ] wasm-bindgen-cli installed at exactly the version of the wasm-bindgen crate in your Cargo.lock.
  • [ ] Binaryen’s wasm-opt (release 116 or newer).
  • [ ] A crate with crate-type = ["cdylib"] and at least one #[wasm_bindgen] export.

What wasm-pack actually does

wasm-pack is a convenience wrapper, and a good one. Under the hood it runs three tools in order and then writes a package.json. Knowing the three steps is useful even if you keep using wasm-pack: it explains its flags, makes its error messages readable, and tells you where to intervene when you need something it does not expose.

The first step is an ordinary cargo build for the wasm32-unknown-unknown target. That produces a .wasm file containing your code, the parts of the standard library you use, and a custom section in which the #[wasm_bindgen] macro has recorded a description of every exported and imported item. The module is not usable from JavaScript yet: functions that take strings take pointers and lengths, and nothing knows how to call them.

The second step, wasm-bindgen, reads that custom section, rewrites the module — removing the description, adjusting exports, adding helper functions — and generates the JavaScript glue that turns greet("world") into “allocate, copy the UTF-8 bytes in, call, read the result back, free”. It can emit glue for several environments, selected with --target.

The third step, wasm-opt, runs Binaryen’s optimizer over the rewritten module. wasm-pack runs it by default for release builds with -O and skips it for dev builds.

The three steps behind wasm-pack build Cargo compiles the crate to a raw .wasm containing a wasm-bindgen description section. The wasm-bindgen CLI consumes that section, rewrites the module and emits JavaScript glue and type declarations. wasm-opt then shrinks the rewritten module. wasm-pack adds a package.json. cargo build wasm32 target, release raw app.wasm code + bindgen section wasm-bindgen glue .js, .d.ts, rewritten .wasm wasm-opt -Oz, strip debug package + package.json (wasm-pack only)

Step 1 — compile

cargo build --release --target wasm32-unknown-unknown
ls -la target/wasm32-unknown-unknown/release/*.wasm
-rwxr-xr-x  1 dev  staff  1843211  target/wasm32-unknown-unknown/release/app.wasm

That file is large because it still contains the bindgen description, names, and anything the later steps will remove. Do not judge size here. The profile settings that matter — opt-level, lto, codegen-units, panic — are covered in shrinking Rust Wasm with Cargo profiles; they apply here exactly as they do under wasm-pack, because wasm-pack runs the same cargo command.

Step 2 — generate bindings

wasm-bindgen target/wasm32-unknown-unknown/release/app.wasm \
  --out-dir pkg \
  --target web \
  --typescript
pkg/
  app.js            # glue: init(), exported functions and classes
  app.d.ts          # TypeScript declarations
  app_bg.wasm       # the rewritten module
  app_bg.wasm.d.ts  # types for the raw exports

The --target flag chooses the glue’s loading strategy. web produces an ES module with an init() function that fetches the binary relative to import.meta.url. bundler produces glue that imports the .wasm file directly and expects a bundler to handle it. nodejs produces CommonJS that reads the file from disk. no-modules produces a classic script that defines a global. deno and experimental-nodejs-module cover the remaining runtimes. These are exactly wasm-pack’s targets, because wasm-pack passes the flag straight through.

Other flags worth knowing: --keep-debug preserves DWARF so source-level debugging works; --debug adds runtime assertions to the glue; --weak-refs makes exported class wrappers free their Rust side automatically through FinalizationRegistry; --reference-types uses externref instead of the heap-slab table for JavaScript values, which shrinks glue for modules that pass many objects.

Step 3 — optimize

wasm-opt pkg/app_bg.wasm -Oz --strip-debug --strip-producers -o pkg/app_bg.wasm
ls -la pkg/app_bg.wasm
-rw-r--r--  1 dev  staff  287344  pkg/app_bg.wasm

Run wasm-opt after wasm-bindgen, never before. The bindgen step needs the description section that an aggressive optimizer would discard, and the glue it generates refers to export names that must still exist. In the other order, wasm-bindgen fails with an error about a missing custom section — or worse, silently produces glue that does not match the module. The passes and levels are discussed in reducing Wasm bundle size with wasm-opt.

Step 4 — script it

Three commands are easy to type and easy to get subtly wrong, so put them in one place. A shell script is enough:

#!/usr/bin/env bash
# scripts/build-wasm.sh — release build without wasm-pack
set -euo pipefail
CRATE=app
PROFILE=${PROFILE:-release}
TARGET=${TARGET:-web}
OUT=${OUT:-pkg}

cargo build --profile "$PROFILE" --target wasm32-unknown-unknown
wasm-bindgen "target/wasm32-unknown-unknown/$PROFILE/$CRATE.wasm" \
  --out-dir "$OUT" --target "$TARGET" --typescript
if [[ "$PROFILE" == "release" ]]; then
  wasm-opt "$OUT/${CRATE}_bg.wasm" -Oz --strip-debug --strip-producers -o "$OUT/${CRATE}_bg.wasm"
fi
echo "built $OUT/${CRATE}_bg.wasm ($(wc -c < "$OUT/${CRATE}_bg.wasm") bytes)"

Note that the cargo profile directory for the built-in dev profile is debug, not dev; if you use custom profiles, map the name accordingly. Running the steps through a task runner works equally well and is more portable across operating systems, as shown in driving Wasm builds with a justfile.

Step 5 — check the CLI and crate versions agree

The one hard constraint of doing this by hand is that the wasm-bindgen CLI version must match the crate version exactly. wasm-pack hides this by downloading the right CLI automatically. Without it, a mismatch fails loudly:

it looks like the Rust project used to create this wasm file was linked against
version of wasm-bindgen that uses a different bindgen format than this binary:

  rust wasm file schema version: 0.2.93
     this binary schema version: 0.2.92

Pin the crate with an = requirement and derive the CLI version from the lockfile in your setup script, as described in pinning Wasm toolchain versions.

wasm-pack versus running the steps yourself wasm-pack is quickest to start with and manages the CLI version; running the steps manually exposes every flag, avoids the extra tool, and fits other build systems, at the cost of managing versions yourself. wasm-pack build one command, sensible defaults downloads a matching bindgen CLI writes package.json for npm flags for wasm-opt are limited best for getting started and npm packages cargo + wasm-bindgen + wasm-opt every flag visible and changeable fits Make, just, Bazel or Nix builds no package.json unless you write one you keep the CLI version in sync best for custom pipelines and debugging

Whichever way you script it, keep the three steps together in one command that everyone runs. The failures that come from doing this by hand are almost never in a single step; they come from running two of the three, or running them from different versions, or forgetting that the dev build skips the optimizer. A single entry point removes all of those, and makes the build reproducible in CI with exactly the same command.

Expected output

After a full run, the package directory matches what wasm-pack would produce for the same target, minus package.json and README.md:

ls pkg && wasm-objdump -x pkg/app_bg.wasm | grep -A3 '^Export\['
app.d.ts  app.js  app_bg.wasm  app_bg.wasm.d.ts
Export[6]:
 - memory[0] -> "memory"
 - func[31]  -> "greet"
 - func[58] <__wbindgen_malloc> -> "__wbindgen_malloc"

If you need the package to be installable with npm, add a small package.json with "type": "module", a main or exports field pointing at app.js, types pointing at app.d.ts, and a files list that includes the .wasm.

Gotchas

  • wasm-bindgen reports a missing custom section. Something stripped it before bindgen ran — usually wasm-opt in the wrong order, or strip = true in the cargo profile. Strip after bindgen, not before.
  • The module works but every export takes numbers instead of strings. You are loading the raw cargo output, not the bindgen output. Load pkg/app_bg.wasm through pkg/app.js.
  • wasm-opt rejects the module with a validation error about a feature. Newer rustc versions enable proposals such as bulk memory or reference types by default; an older Binaryen does not know them. Upgrade Binaryen, or pass the matching --enable-* flags to wasm-opt.
  • Huge glue file. --debug was left on. It adds assertions and descriptive error messages, which belong in dev builds only.
  • Different output from wasm-pack for the “same” build. wasm-pack passes --release to cargo, runs wasm-opt -O rather than -Oz, and may set extra profile flags from [package.metadata.wasm-pack]. Compare the commands, not the names.

Performance note

Run by hand, the three steps took 41 s for a cold release build of a mid-sized crate, of which cargo was 35 s, wasm-bindgen 0.8 s and wasm-opt 5.2 s. wasm-pack took 44 s for the same build — the difference was its own startup and the check for a matching CLI. The real saving was in iteration: skipping wasm-opt for dev builds and calling bindgen directly brought the edit-to-package time down to about two seconds.

Where a release build spends its time Breakdown of a cold release build of the same crate run as three manual steps. Compilation dominates; wasm-bindgen is nearly instant and wasm-opt takes a few seconds. seconds, cold release build cargo build 35 s wasm-bindgen 0.8 s wasm-opt -Oz 5.2 s wasm-pack total, same build 44 s

Frequently Asked Questions

Is wasm-pack still maintained? It is, and it remains the simplest path for packages destined for npm. Building by hand is an alternative for pipelines that need control, not a replacement everyone should adopt.

Can I skip wasm-bindgen entirely? Yes, if your exports only take and return numbers. Then the raw cargo output is usable directly with WebAssembly.instantiate. As soon as strings, objects or closures cross the boundary, the glue earns its place.

Does Trunk use these steps too? Yes. Trunk runs cargo, wasm-bindgen and optionally wasm-opt, then also processes HTML and assets; see building a Rust Wasm app with Trunk.

What changes for a WASI target? Almost everything: there is no wasm-bindgen step and no JavaScript glue. A WASI module runs in a runtime directly, as in compiling Rust to wasm32-wasip1.

← Back to Rust to Wasm Compilation Guide