Caching Rust Wasm Builds in GitHub Actions

This page answers one task: make a GitHub Actions workflow that builds a Rust crate to WebAssembly finish quickly on every push, by caching the things that do not change between runs and rebuilding only what does.

Prerequisites

  • [ ] A Rust crate that builds with wasm-pack build or cargo build --target wasm32-unknown-unknown.
  • [ ] A committed Cargo.lock; caching without one is caching a moving target.
  • [ ] A workflow file under .github/workflows/, such as the one from setting up CI/CD for Rust Wasm projects.
  • [ ] A rough baseline: note how long the build step takes today on a clean run.

Where the minutes go

A cold Rust-to-Wasm build spends its time in four places, and only one of them is your code. The toolchain install adds the wasm32-unknown-unknown target. Cargo downloads and unpacks every crate in the lockfile. It then compiles every dependency — usually the largest share — before compiling your crate. Finally the pipeline installs and runs the post-processing tools: wasm-bindgen-cli, wasm-pack and Binaryen’s wasm-opt. Installing those with cargo install compiles them from source, which on its own can take longer than building your project.

Where a cold Rust Wasm CI build spends its time Breakdown of a seven-minute cold build of a mid-sized crate with about 180 dependencies. Compiling dependencies and installing tools from source dominate; the project's own code is a small fraction. seconds on a standard GitHub-hosted runner toolchain + target 22 s registry download 31 s compile dependencies 212 s compile own crate 38 s cargo install wasm-bindgen-cli 104 s wasm-opt + bindgen run 17 s Caching removes almost all of the dependency compile and the tool install; the 38 seconds of your own crate remain.

The goal of caching is therefore not to make your crate compile faster. It is to stop recompiling everything else.

Step 1 — cache cargo’s home and target directories with a stable key

Swatinem/rust-cache handles the details that hand-rolled actions/cache steps usually get wrong: it caches ~/.cargo/registry and ~/.cargo/git alongside target/, keys on the lockfile and toolchain, and prunes the target directory of your own crate’s artifacts before saving so the cache does not grow every run.

name: build
on: [push, pull_request]

jobs:
  wasm:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: wasm32-unknown-unknown

      - uses: Swatinem/rust-cache@v2
        with:
          # one cache per target triple, so a native test job does not evict this one
          key: wasm32
          cache-on-failure: true

The key input is appended to the cache key the action computes from Cargo.lock, the toolchain version and the job. Use it to separate jobs that build different targets; otherwise a native test job and a Wasm build job fight over one cache and each evicts the other’s artifacts.

Step 2 — install tools as prebuilt binaries, not from source

cargo install wasm-bindgen-cli compiles the CLI and its dependencies every time the cache misses. Prebuilt binaries take seconds. taiki-e/install-action fetches release binaries for a long list of Rust tools and is the simplest option:

      - uses: taiki-e/install-action@v2
        with:
          tool: wasm-bindgen-cli@0.2.93,wasm-pack@0.13.0

      - name: install binaryen
        run: |
          curl -sSL https://github.com/WebAssembly/binaryen/releases/download/version_118/binaryen-version_118-x86_64-linux.tar.gz \
            | tar xz -C "$RUNNER_TEMP"
          echo "$RUNNER_TEMP/binaryen-version_118/bin" >> "$GITHUB_PATH"

Pin the versions. The wasm-bindgen-cli version must match the wasm-bindgen crate version in Cargo.lock exactly, or the build fails with a schema-mismatch error; pinning both in one place avoids the mismatch, as described in pinning Wasm toolchain versions.

Step 3 — build, then keep the artifact

      - name: build
        run: wasm-pack build --release --target web --out-dir pkg

      - uses: actions/upload-artifact@v4
        with:
          name: wasm-pkg-${{ github.sha }}
          path: pkg/
          retention-days: 14

Uploading the package means later jobs — browser tests, a size check, a deploy — download the built module instead of rebuilding it. A build that runs once per commit and is consumed several times is cheaper than one that runs in every job.

One cached build feeding several jobs The build job restores the cargo cache, installs prebuilt tools, builds the package and uploads it as an artifact. Test, size-check and deploy jobs download that artifact instead of compiling again. restore cache registry + target/ prebuilt tools bindgen, wasm-pack, binaryen wasm-pack build only changed crates compile upload artifact pkg/ for this commit test, size, deploy download, never rebuild

Step 4 — check that the cache actually hit

The rust-cache action logs its decisions. On a warm run you should see an exact match:

Run Swatinem/rust-cache@v2
  Cache Configuration
    Workspaces:
      /home/runner/work/app/app
    Cache Paths:
      /home/runner/.cargo/bin
      /home/runner/.cargo/registry
      /home/runner/.cargo/git
      /home/runner/work/app/app/target
    Restore Key:
      v0-rust-wasm32-Linux-x64-8f3b2c1e
  Restored from cache key "v0-rust-wasm32-Linux-x64-8f3b2c1e-6c2a91f0" full match: true.

full match: true means the lockfile and toolchain were unchanged and every dependency artifact is present. A partial match — common after a dependency bump — restores most of target/ and rebuilds only the crates that changed, which is still far faster than cold.

Why the target directory is worth the space

It is tempting to cache only the registry — downloaded source is small and changes rarely — and let target/ rebuild every time. For native Rust projects that is sometimes a reasonable trade. For Wasm builds it gives away most of the benefit, because the expensive part is compiling dependencies to the wasm32-unknown-unknown target, and that work lives in target/wasm32-unknown-unknown/release/deps. Crates like web-sys, js-sys and serde with derive support are compiled once per feature set and take tens of seconds each; restoring their artifacts is what turns a seven-minute build into a one-minute build.

The cost is cache size. A mid-sized Wasm crate’s dependency artifacts are typically 300–800 MB before compression. That fits comfortably within GitHub’s per-repository limit for one or two keys, which is why the advice is to keep the number of keys small rather than to cache less.

It also helps to understand what cargo uses to decide an artifact is still valid. It fingerprints each crate on its source, the exact compiler version, the target, the profile settings, enabled features and the relevant environment. A restored artifact is reused only if every one of those matches, which is reassuring — a stale cache cannot produce a wrong build, only a slow one — and is also why the inputs need to be kept stable for the cache to pay off.

Step 5 — keep the cache from going stale or bloated

GitHub evicts caches beyond 10 GB per repository, least recently used first, and a cache that is never pruned grows with every toolchain or dependency change. Three habits keep it healthy. Save caches only from the default branch, so a hundred pull-request branches do not each write their own:

      - uses: Swatinem/rust-cache@v2
        with:
          key: wasm32
          save-if: ${{ github.ref == 'refs/heads/main' }}

Bump the action’s prefix-key when you change something the automatic key does not see — a new RUSTFLAGS value, for instance, changes every artifact but not the lockfile. And look at the repository’s cache list occasionally under Actions → Caches; a dozen near-identical entries from old toolchains means the key is changing more often than it should.

RUSTFLAGS deserves a specific warning. Setting it in one step but not another, or differently in two jobs that share a key, makes cargo treat every artifact as dirty and rebuild from scratch — on a run that the cache log reports as a full hit. If a warm build is suddenly cold, diff the environment before suspecting the cache.

What a change does to the cached build Common changes and their effect on a rust-cache restore — whether the key still matches, and how much of the build reruns as a result. change cache key what rebuilds edit your own crate full match own crate only bump one dependency partial match that crate and dependents new rustc patch release miss everything RUSTFLAGS differs between runs full match (misleading) everything new wasm-bindgen-cli version full match nothing; binary install

Read the table as a checklist when a warm build is unexpectedly slow: find the row that matches what changed, and the right-hand column tells you whether the slowness is expected or a sign that a build input is drifting between runs.

Expected output

On the same crate, the build step timings before and after:

cold (no cache):          6m 54s
warm (full match):          58s
warm (one dep bumped):    1m 41s

Gotchas

  • it looks like the Rust project used to create this wasm file was linked against version of wasm-bindgen that uses a different bindgen format. The CLI version and the crate version differ. Pin wasm-bindgen-cli to the exact version in Cargo.lock.
  • The cache restores but everything rebuilds. Something changed the fingerprint: RUSTFLAGS, a different toolchain patch version, or a build script that depends on an environment variable. Make those inputs identical across runs.
  • Pull requests from forks never hit the cache. They can read caches from the base branch but not write their own; with save-if restricted to main, that is the intended behaviour.
  • The cache exceeds the limit and thrashes. Too many distinct keys. Restrict saving to the default branch and remove matrix dimensions that do not change the artifacts.

Performance note

The biggest single improvement in the timings above came from installing wasm-bindgen-cli as a binary rather than compiling it: 104 seconds became 3. The cargo cache saved more in total, but it also misses whenever Cargo.lock changes, while the binary install is fast on every run. If you only do one thing, stop compiling your tools.

Frequently Asked Questions

Should I cache target/ at all? It is huge. For Wasm builds, yes — dependency artifacts are where the time goes. rust-cache prunes your own crate’s artifacts and incremental data before saving, which keeps the cache to dependencies only.

Does sccache help more than rust-cache? sccache caches individual compilation units in remote storage and shines with many jobs sharing a large dependency set. For a single Wasm crate, rust-cache is simpler and nearly as fast.

What about Emscripten projects? Cache emsdk’s install directory and EM_CACHE, keyed on the emsdk version. The Emscripten ports page shows how to prebuild the port cache in a setup step.

← Back to Cross-Platform Build Automation