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.

  1. Toolchain install. rustup target add, an emsdk install, or a Go toolchain that already ships the wasm targets.
  2. Compile. The full dependency graph builds again for the wasm triple, into target/<triple> rather than the host directory.
  3. Post-process. wasm-bindgen generates the JavaScript glue, wasm-opt from binaryen shrinks the module.
  4. Test. A runtime executes the module, either wasmtime for WASI targets or a headless browser for wasm32-unknown-unknown with wasm-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:

PathWhat it holdsKey onReused by
~/.cargo/registry and ~/.cargo/gitCrate sources and the registry indexCargo.lock hashEvery Rust job
target/wasm32-unknown-unknown, target/wasm32-wasip1rlibs and wasm objects for the wasm triplesCargo.lock plus toolchain versionCompile, test
~/.cargo/binInstalled tool binaries: wasm-pack, wasm-bindgen, wasm-optPinned tool versionsCompile, post-process
~/.cache/.wasm-packBinaries wasm-pack downloads on first usewasm-pack versionCompile
~/emsdk and $EM_CACHEThe Emscripten SDK plus its compiled sysroot portsemsdk versionCompile (Emscripten projects)
~/.cache/go-build, ~/go/pkg/modGo build cache for GOOS=wasip1 and module downloadsgo.sum hashCompile, 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-wasip1

Four 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.64

The 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 labelvCPUMemoryStoragePrice per minute
warp-ubuntu-latest-x64-2x28 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664 GB150GB 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 itemRateVolumeMonthly cost
GitHub-hosted Linux 8 vCPU larger runner$0.022 per minute6,300 minutes$138.60
warp-ubuntu-latest-x64-8x$0.016 per minute6,300 minutes$100.80
WarpBuild cache storage$0.20 per GB-month6 GB$1.20
WarpBuild cache operations$0.0001 per operation4,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-16x for the heavy compile and test legs and warp-ubuntu-2404-x64-8x for 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-4x jobs, 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.