Pinning Wasm Toolchain Versions
This page answers one question: which versions in a WebAssembly build need pinning, where does each pin live, and how do you check in CI that nobody’s machine has drifted?
Prerequisites
- [ ] A Rust or C/C++ project that compiles to Wasm.
- [ ] Commit access to the repository root (several pins are files there).
- [ ] A list of the tools your build runs — if you cannot write it down, that is the first thing to fix.
The five things that change your output
A WebAssembly build is a pipeline of separately versioned tools, and each one can change the bytes you ship. The compiler decides code generation and which proposals are enabled by default. The binding generator decides the glue code and the custom section format. The optimizer decides the final instruction stream. The runtime library, for Emscripten builds, decides the JavaScript loader. And the JavaScript bundler decides how the module is referenced and named.
Rust and LLVM releases have changed default target features before — enabling bulk-memory or
reference-types by default — which produces modules that older engines reject. That is a behaviour change
arriving silently through a toolchain update, and the only defence is to make updates explicit.
What “pinned” has to mean
A pin is only useful if it is exact, enforced and discoverable. Exact means a full version, not a range or a
channel name: 1.81.0 rather than stable, =0.2.93 rather than 0.2. Enforced means the build either
installs that version automatically or refuses to run with anything else — a version written in a README that
nothing checks is a suggestion. Discoverable means someone upgrading the toolchain can find every pin from one
place, rather than finding the fifth one three weeks later when a release build behaves differently.
The steps below go through each component in that spirit. Some pins are enforced for free by the tool that
reads them, like rust-toolchain.toml; others need a small script to check them. Together they cover every
version that can change what the build produces.
It is worth being clear about what pinning does not do. It does not make builds reproducible on its own — two builds with identical tools can still differ if they embed paths or timestamps. And it does not protect you from the platform: a browser update can still change how your module performs, because the engine is not part of your build. Pinning makes the build a controlled variable, so that when something does change, the build is not one of the suspects.
Step 1 — pin rustc with rust-toolchain.toml
# rust-toolchain.toml — at the repository root
[toolchain]
channel = "1.81.0"
targets = ["wasm32-unknown-unknown", "wasm32-wasip1"]
components = ["rustfmt", "clippy"]
profile = "minimal"
rustup reads this file automatically in any directory beneath it: cargo build installs 1.81.0 and the
listed targets on first use and uses them from then on. Pin a full version, not stable — stable is a moving
pointer and defeats the purpose.
Step 2 — pin wasm-bindgen to an exact version, in both places
The wasm-bindgen crate embeds a schema version in the module; the CLI refuses to process a module produced by
a different version. Use an = requirement so cargo update cannot move it:
# Cargo.toml
[dependencies]
wasm-bindgen = "=0.2.93"
js-sys = "=0.3.70"
web-sys = { version = "=0.3.70", features = ["Window", "Document"] }
Then install the CLI at the same version everywhere — in CI, in the container image, and in a bootstrap script for developers:
# scripts/bootstrap.sh
BINDGEN=$(grep -A1 'name = "wasm-bindgen"' Cargo.lock | grep version | cut -d'"' -f2)
cargo install wasm-bindgen-cli --version "$BINDGEN" --locked
Deriving the CLI version from Cargo.lock means there is exactly one place the version is written, and the
script cannot install the wrong one.
Step 3 — pin Binaryen and emsdk to releases
Binaryen ships numbered releases; take one and record it. For emsdk, a version-file in the repository lets every build activate the same SDK:
# .emsdk-version
3.1.61
# scripts/emsdk-activate.sh
EMSDK_VERSION=$(cat .emsdk-version)
./emsdk/emsdk install "$EMSDK_VERSION"
./emsdk/emsdk activate "$EMSDK_VERSION"
source ./emsdk/emsdk_env.sh
The emsdk version also pins the LLVM, Binaryen and Node versions bundled with it, which makes it the single most important pin in a C/C++ build.
Step 4 — record every version in one place CI can check
Pins scattered across five files are easy to forget when upgrading. A small manifest makes them visible and checkable:
# toolchain.toml — documentation and the source of truth for CI checks
rustc = "1.81.0"
wasm-bindgen = "0.2.93"
binaryen = "118"
wasm-pack = "0.13.0"
emsdk = "3.1.61"
node = "20.17.0"
#!/usr/bin/env bash
# scripts/check-toolchain.sh — fail fast when a machine has drifted
set -euo pipefail
want() { grep "^$1 " toolchain.toml | cut -d'"' -f2; }
check() { [[ "$2" == *"$(want "$1")"* ]] || { echo "✗ $1: want $(want "$1"), have $2"; exit 1; }; echo "✓ $1 $2"; }
check rustc "$(rustc --version)"
check wasm-bindgen "$(wasm-bindgen --version)"
check binaryen "$(wasm-opt --version)"
check wasm-pack "$(wasm-pack --version)"
Run the script as the first CI step and as part of the local build target. A drifted developer machine then fails with a one-line explanation instead of producing a subtly different module.
Step 5 — pin the JavaScript side too
The bundler and its Wasm plugins are part of the toolchain. A committed package-lock.json or
pnpm-lock.yaml pins them as long as installs use the lockfile:
npm ci # not npm install — ci refuses to modify the lockfile
Pin Node itself with an .nvmrc or the engines field plus engine-strict, because Node versions change
WebAssembly support: recent releases enable features like JSPI or memory64 behind or without flags, and a test
that passes on one Node release can fail on another.
Upgrading on purpose
Pinning is only half of the policy; the other half is upgrading regularly enough that the pins do not fossilise. A toolchain left untouched for a year accumulates missed optimizations, missed security fixes and an upgrade so large it becomes a project of its own. The habit that works is a small, scheduled upgrade — one component per pull request — with the effect measured.
Each of those changes is boring on its own, which is the point. When a size regression or a behaviour change does show up, it arrives alone, with one obvious cause, in a pull request that can be reverted. Compare that with the alternative: three tools upgraded at once on a developer’s machine, a module two percent larger, and no way to say which tool did it.
Expected output
$ ./scripts/check-toolchain.sh
✓ rustc rustc 1.81.0 (eeb90cda1 2024-09-04)
✓ wasm-bindgen wasm-bindgen 0.2.93
✓ binaryen wasm-opt version 118 (version_118)
✓ wasm-pack wasm-pack 0.13.0
And on a machine that has drifted:
✓ rustc rustc 1.81.0 (eeb90cda1 2024-09-04)
✗ wasm-bindgen: want 0.2.93, have wasm-bindgen 0.2.92
Gotchas
cargo updatesilently moves wasm-bindgen. A caret requirement allows it. Use=0.2.93and upgrade deliberately.- CI uses
stablefrom a setup action. Many Rust setup actions ignorerust-toolchain.tomlif given an explicittoolchaininput. Remove the input so the file wins. - Binaryen from a distro package. Linux package managers ship old Binaryen releases that miss passes and features. Download the release binary instead.
- Pinned toolchain, unpinned base image. A
FROM rust:latestin a Dockerfile undoes every other pin. Pin the image tag too, as in building Wasm in Docker.
Performance note
On one project, a routine Binaryen upgrade from release 116 to 118 shrank the optimized module by 2.1% and changed its hash; a rustc upgrade in the same month grew it by 0.8%. Neither was a problem — but without pins both arrived unannounced on different developers’ machines and made a week of size-regression checks meaningless. Pinned, each landed as a single reviewed change with its delta in the description, which is how catching size regressions in CI is supposed to work.
Frequently Asked Questions
How often should I upgrade? On a schedule rather than by accident — monthly or per Rust release works for most teams. Upgrade one component at a time so a size or speed change can be attributed.
Does wasm-pack pin wasm-bindgen for me?
wasm-pack downloads a matching wasm-bindgen-cli automatically, which removes one class of mismatch. Pinning
wasm-pack’s own version still matters, since its defaults change between releases.
Do I need to pin the WASI SDK or wasi-libc? Yes, for C builds targeting WASI. The SDK bundles clang, wasi-libc and the sysroot together; record the SDK release number in the manifest the same way as emsdk, and download that release in the build image.
What about nightly-only features?
Pin a dated nightly — channel = "nightly-2024-09-01" — never plain nightly, and record why you need it, so
the pin can be removed when the feature stabilises.
Related
- Producing reproducible Wasm binaries — pins are the prerequisite for identical output.
- Choosing a Rust Wasm target triple — the targets listed in the toolchain file.
- Reducing Wasm bundle size with wasm-opt — why the Binaryen version moves your numbers.
- Detecting proposal support at runtime — guarding against new default features.
← Back to Cross-Platform Build Automation