WebAssembly Builds on GitHub Actions
WebAssembly builds on GitHub Actions need the wasm target installed, a cached target directory, and a runtime for tests. Workflow YAML and runner rates inside.
Last verified:
A WebAssembly build on GitHub Actions is a cross-compile: the workflow installs a wasm target next to the host toolchain, compiles the dependency graph a second time into a separate target directory, and then runs the test suite through a wasm runtime instead of executing a native binary. Wall-clock time is set by two things, the core count during that second compile and whether the wasm target directory and the tool downloads survive between jobs.
WarpBuild runs both halves on warp- labeled Linux runners billed per minute, with cache enabled by default. This page covers the toolchain setup and the exact cache paths for the wasm target, a workflow that builds a wasm artifact and tests it, sizing for the compile step with rates applied, and the failure modes that dominate wasm pipelines.
Overview
A wasm pipeline has four phases, and each one wastes time differently on a cold machine.
- Toolchain install.
rustup target add, anemsdk install, or a Go toolchain that already ships the wasm targets. - Compile. The full dependency graph builds again for the wasm triple, into
target/<triple>rather than the host directory. - Post-process.
wasm-bindgengenerates the JavaScript glue,wasm-optfrom binaryen shrinks the module. - Test. A runtime executes the module, either
wasmtimefor WASI targets or a headless browser forwasm32-unknown-unknownwithwasm-bindgen-test.
Every GitHub Actions job starts on a fresh virtual machine, so each of those phases repeats from zero unless something restores its state. These are the directories worth carrying between runs:
| Path | What it holds | Key on | Reused by |
|---|---|---|---|
~/.cargo/registry and ~/.cargo/git | Crate sources and the registry index | Cargo.lock hash | Every Rust job |
target/wasm32-unknown-unknown, target/wasm32-wasip1 | rlibs and wasm objects for the wasm triples | Cargo.lock plus toolchain version | Compile, test |
~/.cargo/bin | Installed tool binaries: wasm-pack, wasm-bindgen, wasm-opt | Pinned tool versions | Compile, post-process |
~/.cache/.wasm-pack | Binaries wasm-pack downloads on first use | wasm-pack version | Compile |
~/emsdk and $EM_CACHE | The Emscripten SDK plus its compiled sysroot ports | emsdk version | Compile (Emscripten projects) |
~/.cache/go-build, ~/go/pkg/mod | Go build cache for GOOS=wasip1 and module downloads | go.sum hash | Compile, test (Go projects) |
The wasm target directory is the entry people leave out. Cargo keys build artifacts by target triple, so a repository that runs host tests and a wasm build gets two independent artifact trees under target/, and restoring only the host one leaves the wasm compile completely cold. The background on why the second tree exists at all is on the cross-compilation glossary page.
Wasm work belongs on Linux x64: the toolchains are all Linux-native, and the cloud runner catalog lists Ubuntu 24.04 and 26.04 images from 2 to 32 vCPUs with 150GB SSDs, carrying the same tooling as GitHub-hosted images so a preinstalled headless Firefox is available for browser tests.
Configuration
This workflow builds a Rust crate to wasm32-unknown-unknown, optimizes the module, uploads it as an artifact, and runs both a browser suite and a WASI suite against the same cache entry.
name: wasm
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal
- run: rustup target add wasm32-unknown-unknown wasm32-wasip1
- uses: WarpBuilds/rust-cache@v2
with:
cache-provider: warpbuild
shared-key: wasm
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Restore wasm tooling
uses: WarpBuilds/cache@v1
with:
path: |
~/.cargo/bin/wasm-pack
~/.cargo/bin/wasm-bindgen
~/.cache/.wasm-pack
key: ${{ runner.os }}-wasm-tools-${{ hashFiles('rust-toolchain.toml') }}
restore-keys: |
${{ runner.os }}-wasm-tools-
- run: cargo install wasm-pack --locked --version 0.13.1
- run: wasm-pack build --release --target web --out-dir pkg
- run: sudo apt-get install -y binaryen
- run: wasm-opt -Oz pkg/*_bg.wasm -o pkg/module.wasm
- uses: actions/upload-artifact@v4
with:
name: wasm-pkg
path: pkg
test:
runs-on: warp-ubuntu-latest-x64-4x
env:
CARGO_TARGET_WASM32_WASIP1_RUNNER: wasmtime
steps:
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal
- run: rustup target add wasm32-unknown-unknown wasm32-wasip1
- uses: WarpBuilds/rust-cache@v2
with:
cache-provider: warpbuild
shared-key: wasm
- run: cargo install wasm-pack --locked --version 0.13.1
- run: wasm-pack test --headless --firefox
- name: Install wasmtime
run: |
curl https://wasmtime.dev/install.sh -sSf | bash
echo "$HOME/.wasmtime/bin" >> "$GITHUB_PATH"
- run: cargo test --target wasm32-wasip1Four details carry most of the value here.
Add the targets before the cache step. WarpBuilds/rust-cache folds the rustc version and the installed target list into its key, so rustup target add has to run first or the restore keys against a toolchain that does not match what compiles.
shared-key: wasm gives both jobs one entry. The build job saves on main, the test job restores, and pull request branches restore from the main entry without writing their own. Without the shared key each job keys separately and compiles the wasm dependency graph twice per run.
Pin the tool version and cache the binary. cargo install wasm-pack compiles the tool from source, which takes minutes on a cold machine. Caching ~/.cargo/bin/wasm-pack keyed on the pinned version turns the second run into a no-op, and ~/.cache/.wasm-pack holds what wasm-pack itself downloads.
CARGO_TARGET_WASM32_WASIP1_RUNNER makes cargo test work at all. Cargo builds a wasm test binary that the runner cannot execute directly; the runner variable tells it to hand each binary to wasmtime. Browser targets take the other route through wasm-pack test --headless.
For an Emscripten project, replace the tooling cache with the SDK and its port cache, and point EM_CACHE at a path inside the cached directory:
- uses: WarpBuilds/cache@v1
with:
path: |
~/emsdk
~/.emscripten_cache
key: ${{ runner.os }}-emsdk-3.1.64The generated pkg directory belongs in an artifact rather than a cache, since it is a per-commit result a publish job consumes; the build artifacts guide covers retention and download on the consuming side. Both cache actions above are drop-in replacements for their upstream counterparts, and the full list of cache-enabled setup-* forks is in the setup actions documentation. Cache storage meters at $0.20 per GB-month with each write or restore at $0.0001, so a wasm repository holding 6 GB across host and wasm target trees and running 4,000 operations a month adds about $1.60.
Sizing
The Linux x64 sizes that matter for wasm work, with per-minute rates from the pricing page:
| Runner label | vCPU | Memory | Storage | Price per minute |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 |
| warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 |
| warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 |
The compile step scales with cores, and it is the phase to size for. Compiling to wasm is still rustc compiling crates, so the dependency graph fans out across vCPUs exactly as a host build does. The difference is volume: a repository that builds for both the host and a wasm triple runs the graph twice, which is why 8 vCPUs earn their rate on a wasm job in repositories where 4 was enough for the host build alone. Move to 16x when the CPU chart stays saturated through the whole compile rather than tailing off into the final few crates.
Post-processing is a smaller, partly parallel phase. wasm-opt spreads its passes across functions, so it uses more than one core on a module with many functions, but a single very large function or a -Oz run on one module still leaves cores idle. Size for the compile and let the optimizer take what it takes.
Test jobs want fewer cores. A headless browser suite is bounded by how many browser instances the job starts, and a wasmtime suite runs one module per test binary. 4x at $0.008 per minute is usually the right shape, which is why the workflow above splits the two jobs onto different labels rather than paying compile-sized rates for browser startup.
GitHub publishes its own per-minute list prices on the GitHub Actions minute multipliers reference. Checked on 2026-08-13, the 8-core Linux larger runner is $0.022 per minute. A worked model for a wasm project running 900 pull request jobs a month at an average of 7 minutes on 8 vCPUs, which is 6,300 runner minutes:
| Line item | Rate | Volume | Monthly cost |
|---|---|---|---|
| GitHub-hosted Linux 8 vCPU larger runner | $0.022 per minute | 6,300 minutes | $138.60 |
| warp-ubuntu-latest-x64-8x | $0.016 per minute | 6,300 minutes | $100.80 |
| WarpBuild cache storage | $0.20 per GB-month | 6 GB | $1.20 |
| WarpBuild cache operations | $0.0001 per operation | 4,000 operations | $0.40 |
That is $102.40 against $138.60 for the same minutes, a difference of $36.20 per month. Stated as list-price arithmetic: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13.
Bottlenecks
A cold wasm target tree. This is the single largest cost in most wasm pipelines. Without a restored target/wasm32-unknown-unknown, every push recompiles every dependency for the wasm triple from source, including crates that have not changed in months. The shared-key configuration above is the fix; verify it by checking that the second run's compile log shows a small number of crates rather than the whole graph.
Tools rebuilt from source every run. cargo install wasm-pack and cargo install wasm-bindgen-cli are full compiles. Pin the versions, cache the resulting binaries under ~/.cargo/bin, and key on the pinned version rather than on the lockfile so an unrelated dependency bump does not throw the binaries away.
An empty Emscripten port cache. The first emcc invocation on a fresh machine compiles the libc and libc++ ports it needs before touching your code. Caching $EM_CACHE alongside ~/emsdk removes that from every run after the first, and pinning the emsdk version in the cache key keeps the two in step.
wasm-opt on large modules in pull requests. Optimization cost grows with module size, and -Oz on a multi-megabyte module can rival the compile itself. Run the aggressive level on the main branch and release tags, and let pull requests build with the default wasm-pack optimization so review feedback stays quick.
Browser test startup and flake. Headless browser suites pay a fixed startup cost per instance and fail differently from native tests, usually with a timeout rather than an assertion. Keeping them in their own job on a smaller label means a browser flake reruns a 4x job instead of an 8x compile.
Artifacts that grow quietly. A pkg directory with the wasm module, JavaScript glue, and source maps can reach tens of megabytes per commit. Set an explicit retention on the upload step rather than accepting the default.
Observability separates a compile saturating all cores from a job stalled downloading a toolchain, and the Action Debugger pauses the workflow and opens an SSH session on the runner so you can inspect the target directory on the machine itself.
Proof
Public repositories running warp- labels are checkable evidence, and the check takes one click: open the workflow file and read the runs-on line.
- near/nearcore runs the NEAR protocol node's GitHub Actions pipeline on
warp-ubuntu-2404-x64-16xfor the heavy compile and test legs andwarp-ubuntu-2404-x64-8xfor the lighter ones, checked on 2026-08-13. That is the same split this page recommends: compile-sized labels for the graph, smaller labels for everything else. - FuelLabs/sway builds and tests the Sway compiler toolchain on
warp-ubuntu-latest-x64-4xjobs, checked on 2026-08-13. A compiler repository that emits bytecode for a virtual machine target has the same two-tree cargo layout a wasm repository has.
Moving a wasm workflow over is a one-line change per job to runs-on, plus the cache configuration above. Rates for every size and platform are on the pricing page, and the full Linux x64 catalog with images and aliases is on the Linux x64 runner page. The compile-side detail behind the sizing here, including codegen units and link behavior, is covered in depth on the Rust builds on GitHub Actions page.
FAQ
Which runner size should a WebAssembly build start on?
Start the compile job on warp-ubuntu-latest-x64-8x at $0.016 per minute. A wasm build compiles the whole dependency graph a second time for the wasm target, so the crate-level parallelism that fills 8 vCPUs on a host build fills them again here. Keep headless browser test jobs on warp-ubuntu-latest-x64-4x at $0.008 per minute, since those are bounded by browser instances rather than by cores.
What has to be cached for a Rust WebAssembly build?
Four things: the cargo registry and git checkouts under ~/.cargo, the target directory including the target/wasm32-unknown-unknown subtree, the installed tool binaries under ~/.cargo/bin such as wasm-pack and wasm-bindgen, and wasm-pack's own download directory at ~/.cache/.wasm-pack. Emscripten projects add ~/emsdk and the EM_CACHE directory instead of the last two.
Can a wasm test suite run on a GitHub Actions runner?
Yes, through a runtime. Set CARGO_TARGET_WASM32_WASIP1_RUNNER to wasmtime and cargo test executes each wasm test binary through wasmtime; for browser targets, wasm-pack test --headless --firefox drives the preinstalled headless browser on the Ubuntu image. Both run on a standard Linux x64 runner with no extra hardware.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.