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-cliinstalled at exactly the version of thewasm-bindgencrate in yourCargo.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.
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.
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-bindgenreports a missing custom section. Something stripped it before bindgen ran — usuallywasm-optin the wrong order, orstrip = truein 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.wasmthroughpkg/app.js. wasm-optrejects 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 towasm-opt.- Huge glue file.
--debugwas 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
--releaseto cargo, runswasm-opt -Orather 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.
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.
Related
- Best practices for wasm-pack configuration — when to keep the wrapper.
- Reading the glue code wasm-bindgen generates — what step 2 produces.
- Choosing a Rust Wasm target triple — the target used in step 1.
- Tuning LTO and codegen-units for Wasm — profile settings that shape step 1’s output.
← Back to Rust to Wasm Compilation Guide