Setting up CI/CD for Rust Wasm projects
This guide gives you a single, copy-pasteable GitHub Actions workflow that compiles a Rust crate to
wasm32-unknown-unknown, caches the expensive steps, packages it with wasm-pack, shrinks it with
wasm-opt, runs it in real browsers, validates the binary, and deploys it — reproducibly, on every push.
The workflow below is deliberately one job rather than several. A Wasm build is a strict chain — compile, bind, optimize, validate, test — where every stage consumes the previous stage’s output, so splitting it across jobs buys nothing but artifact uploads between them. What does branch is the ending: a pull request stops after the tests, while a push to the default branch continues into publish and deploy. Getting that split right is what lets you run the full quality gate on every change without ever shipping from an unreviewed branch.
Prerequisites
- [ ] A Rust crate with
crate-type = ["cdylib", "rlib"]and awasm-bindgendependency - [ ] A committed
rust-toolchain.tomlpinning the channel and thewasm32-unknown-unknowntarget - [ ]
wasm-pack0.13.x and awasm-bindgen-cliversion matching yourCargo.lock - [ ]
binaryen(wasm-opt) andwasm-tools(wasm-validate) for the optimize and validate steps - [ ] A GitHub repository with Actions enabled and a deploy target (Pages, a CDN, or an artifact store)
Procedure
1. Pin the toolchain in-repo
Commit a rust-toolchain.toml so every runner resolves the same compiler instead of a moving stable.
[toolchain]
channel = "1.83.0"
targets = ["wasm32-unknown-unknown"]
2. Lay out the workflow trigger and job
Create .github/workflows/wasm.yml. Trigger on pushes and pull requests so every change is gated.
name: wasm-ci
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
3. Cache the registry and build directory
Key the cache on the OS, the lockfile hash, and the toolchain file. Including rust-toolchain.toml in the
hash is what prevents a stale target/ from being restored after a compiler bump.
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: wasm-${{ runner.os }}-${{ hashFiles('rust-toolchain.toml', '**/Cargo.lock') }}
restore-keys: |
wasm-${{ runner.os }}-
4. Install pinned tooling
Install wasm-pack and a wasm-bindgen-cli whose version exactly matches the wasm-bindgen crate in
Cargo.lock. A mismatch here is the single most common source of a broken module.
- name: Install tooling
run: |
cargo install wasm-pack --version 0.13.1 --locked
cargo install wasm-bindgen-cli --version 0.2.100 --force
sudo apt-get update && sudo apt-get install -y binaryen wabt
5. Build, optimize, and validate
Build with wasm-pack, shrink with wasm-opt -Oz, then assert structural validity with wasm-validate
before anything downstream trusts the artifact.
- name: Build and optimize
run: |
wasm-pack build --target web --release --out-dir pkg
wasm-opt pkg/*_bg.wasm -Oz --strip-debug -o pkg/optimized_bg.wasm
mv pkg/optimized_bg.wasm pkg/$(basename pkg/*_bg.wasm)
wasm-validate pkg/*_bg.wasm
6. Run headless browser tests
wasm-pack test --headless instantiates the module in real engines and asserts the ES module resolves.
- name: Test in browsers
run: wasm-pack test --headless --chrome --firefox
7. Gate and deploy
Upload the validated pkg/ and deploy it only from main, so pull requests are tested but never publish.
- uses: actions/upload-artifact@v4
with:
name: wasm-pkg
path: pkg/
- name: Deploy
if: github.ref == 'refs/heads/main'
run: npx wrangler pages deploy pkg --project-name my-wasm-app
8. Fail the build on a size regression
A validated binary can still be a bad one. Because the compressed size of the module is what users actually download, treat it as a test: record the number, compare it against the branch you are merging into, and fail when it grows past a budget you chose deliberately.
- name: Enforce the size budget
run: |
SIZE=$(brotli -q 11 -c pkg/*_bg.wasm | wc -c)
echo "brotli size: $SIZE bytes"
test "$SIZE" -le 90000 || {
echo "::error::wasm exceeds the 90 KB budget ($SIZE bytes)"
exit 1
}
A fixed ceiling is the simplest version and the easiest to reason about in review. The stricter variant compares against the base branch’s artifact and fails on any increase beyond a tolerance, which catches slow accumulation that a generous fixed ceiling would hide for months. Either way the number belongs in the workflow, not in someone’s memory: a budget that lives only in a code-review convention stops being enforced the first busy week.
Expected output
A successful run prints the wasm-pack and wasm-opt summaries, then the validation line. The cache
restore at the top of a warm run is the tell that step 3 is working:
Cache restored from key: wasm-Linux-3f9a...
[INFO]: Compiling to Wasm...
Compiling my-wasm-app v0.1.0
[INFO]: :-) Done in 9.42s
[INFO]: :-) Your wasm pkg is ready to publish at ./pkg.
wasm-opt: shrank 184.2 KB -> 71.8 KB
module is structurally valid
running 3 tests
test result: ok. 3 passed; 0 failed
Gotchas
RuntimeError: invalid magic number in the browser. The runner’s
~/.cargo/bin/wasm-bindgen is stale relative to the wasm-bindgen crate in Cargo.lock. Force a
matching reinstall in CI: cargo install wasm-bindgen-cli --version 0.2.100 --force. Without --force,
cargo install skips the already-present binary and the mismatch persists.
error: failed to select a version ... --locked. Your Cargo.lock is out of date with Cargo.toml.
Run cargo update -p <crate> locally, commit the regenerated lockfile, and re-push — do not drop
--locked, because that would let CI silently resolve different versions than your machine.
wasm-opt: out of memory mid-pipeline. Hosted runners cap at ~7 GB and -Oz on a large module can
abort. Confirm the optimizer is the cause with wasm-pack build --dev (which skips wasm-opt); if that
passes, move the optimize step to a larger runner or pin a Binaryen version known to fit.
Cache hit but a different binary than local. The cache key omitted the toolchain. Add
rust-toolchain.toml to hashFiles(...) so a compiler bump invalidates the target/ cache instead of
restoring object files compiled by the old toolchain.
Headless tests pass locally but hang on the runner. wasm-pack test --headless drives a real
browser, and hosted runners ship them without the sandbox permissions a desktop session has. A run
that stalls until the job timeout is almost always the browser failing to launch rather than the test
looping. Print the driver’s log with --verbose, and pin the browser to the one the runner image
actually provides rather than requesting both Chrome and Firefox on an image that carries only one.
Deploy runs on a fork’s pull request. A workflow that branches on github.event_name rather than
on the ref will happily deploy from a fork if the event is a push to that fork’s default branch.
Condition the deploy on github.ref == 'refs/heads/main' and on the repository being your own, and
keep deploy credentials in an environment that requires approval, so a mistaken condition cannot leak
them.
Performance note
The ~/.cargo plus target/ cache is the difference between a cold and a warm run. A cold build of a
small wasm-bindgen crate on ubuntu-latest spends most of its time compiling dependencies — commonly
60–90 seconds. With the registry and target/ directory restored, the same job recompiles only the
changed crate and finishes in roughly 10–15 seconds, a 5–8× wall-time reduction on the build step alone.
The cache restore itself costs a second or two, so it pays for itself after the first dependency.
Two details decide whether that saving is real. The first is the key: it must include the runner OS,
the hash of Cargo.lock, and the pinned toolchain version, because object files compiled by one
compiler are not reusable by another and restoring them produces a build that is fast and wrong. The
second is what you cache. ~/.cargo/registry and ~/.cargo/git are pure downloads and always worth
keeping; target/ is compiled output and grows without bound across dependency churn, so cap it with
a rolling key that falls back to the most recent partial match rather than accumulating gigabytes of
objects nobody links against. When a cache stops helping, it is nearly always because the key changes
on every run — print the resolved key in the log and check that two consecutive builds of the same
commit produce the same one.
Frequently Asked Questions
Do I need a matrix across operating systems for a Rust Wasm build?
Not for correctness of the artifact, which is host-independent, but a matrix across ubuntu-latest,
macos-latest, and windows-latest catches toolchain and path bugs that only appear on one OS. Start
single-OS, add the matrix when portability matters — the
Cross-Platform Build Automation
guide shows the full matrix form.
Can I skip wasm-opt in CI to save time?
For pull-request builds, yes — wasm-pack build --dev skips it and gives faster feedback. Keep the full
-Oz pass on the main deploy path so what you ship is the optimized, size-gated binary.
How do I block merges that bloat the .wasm?
Capture the post-compression size with brotli -c pkg/*_bg.wasm | wc -c, compare it to the main
baseline in a PR step, and fail when it regresses beyond your budget.
Related
- Reducing Wasm bundle size with wasm-opt — the optimizer passes this pipeline runs.
- Best practices for wasm-pack configuration — tuning the build the workflow invokes.
- Rust to Wasm compilation guide — the toolchain underpinning these jobs.
← Back to Cross-Platform Build Automation