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.

What each toolchain component changes when it moves A matrix of the five pinned components against what a version change affects — binary size, generated JavaScript, and whether a mismatch fails the build outright. component changes size changes glue mismatch breaks build rustc / clang yes, codegen no new defaults wasm-bindgen crate + CLI slightly yes yes, must match Binaryen wasm-opt yes, passes no rarely emsdk yes yes flag renames bundler + plugins no loader code sometimes

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.

How a toolchain upgrade travels once versions are pinned An upgrade starts as a one-line change to the manifest and the matching pin files, CI verifies the toolchain and rebuilds, size and benchmark checks show the effect, and the change merges with its measured impact recorded. edit pins toolchain.toml + pin files verify script CI installs and checks build + size check diff against main benchmarks speed delta measured merge impact in the PR

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.

A year of scheduled toolchain upgrades A timeline of one component upgraded per change across a year — rustc each quarter, Binaryen twice, wasm-bindgen when a needed fix landed, and emsdk once — each landing as a separate reviewed change with its size delta recorded. 2 wk rustc 1.78 → 1.79 (+0.4%) 9 wk Binaryen 116 → 117 (−1.2%) 14 wk rustc → 1.80 (+0.2%) 22 wk wasm-bindgen 0.2.92 → 0.2.93 27 wk rustc → 1.81 (−0.3%) 35 wk emsdk 3.1.56 → 3.1.61 41 wk Binaryen → 118 (−0.9%) 50 wk rustc → 1.82 (+0.1%)

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 update silently moves wasm-bindgen. A caret requirement allows it. Use =0.2.93 and upgrade deliberately.
  • CI uses stable from a setup action. Many Rust setup actions ignore rust-toolchain.toml if given an explicit toolchain input. 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:latest in 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.

← Back to Cross-Platform Build Automation