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.

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

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.

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.

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