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.

One job, two endings Checkout, toolchain, cache, build, optimize, validate and browser tests run identically for every trigger. After the tests the workflow branches: a pull request uploads the artifact and stops, while a push to the default branch also deploys. pull_request any branch push: main after review the shared chain — identical for both toolchain → cache → build → bindgen → wasm-opt → validate → headless tests upload artifact · stop reviewers can download pkg/ deploy guarded by an explicit ref check if: main Splitting the chain into separate jobs only adds artifact round-trips: every stage needs the bytes the previous one produced. The one thing worth parallelising is the browser matrix, because those runs are independent of each other.

Prerequisites

  • [ ] A Rust crate with crate-type = ["cdylib", "rlib"] and a wasm-bindgen dependency
  • [ ] A committed rust-toolchain.toml pinning the channel and the wasm32-unknown-unknown target
  • [ ] wasm-pack 0.13.x and a wasm-bindgen-cli version matching your Cargo.lock
  • [ ] binaryen (wasm-opt) and wasm-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.

The size budget as a build gate Three successive pull requests measured against a fixed compressed-size ceiling. The first two land under budget; the third crosses it and fails the run before the artifact can be published. 90 KB 71 KB PR #41 · pass 84 KB PR #42 · pass 103 KB PR #43 · fails here brotli -q 11 bytes shipped a new dependency pulled in formatting machinery — caught before it merges Measure after compression: raw byte counts overstate the impact of code that compresses well.

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
The three versions that must agree The wasm-bindgen version in the lockfile determines the metadata compiled into the binary. The CLI binary installed on the runner reads that metadata and writes the glue. If the two differ, the emitted glue does not match the binary and instantiation fails at run time. Cargo.lock wasm-bindgen = 0.2.100 compiled into the binary runner's CLI binary ~/.cargo/bin/wasm-bindgen often cached from an older run generated glue must match both, or neither works any drift here → RuntimeError: invalid magic number cargo install wasm-bindgen-cli --version 0.2.100 --force — the --force is what makes a cached runner honest.

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.

← Back to Cross-Platform Build Automation