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 buildorcargo 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.
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.
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.
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. Pinwasm-bindgen-clito the exact version inCargo.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-ifrestricted tomain, 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.
Related
- Building Wasm in Docker for consistent output — a container image as an alternative to per-run tool installs.
- Catching size regressions in CI — a consumer of the uploaded artifact.
- Tracking benchmark results in CI — another job that should reuse the build.
- Best practices for wasm-pack configuration — the build flags being cached.
← Back to Cross-Platform Build Automation