Running Wasm Builds on ARM64 Runners

This page answers one task: developers use Apple Silicon Macs, CI offers cheaper or faster ARM64 runners, and you want WebAssembly builds to run natively on ARM64 — without emulation, without missing tools, and with output identical to the x86-64 builds.

Prerequisites

  • [ ] A WebAssembly project with a working x86-64 build.
  • [ ] Access to ARM64 machines: Apple Silicon, ARM64 GitHub Actions runners, AWS Graviton or similar.
  • [ ] The list of tools the build uses, with versions.

WebAssembly is the easy case for ARM

WebAssembly output is architecture-independent: a .wasm file built on an ARM64 machine is the same kind of file as one built on x86-64, and — with pinned toolchains and deterministic settings — byte-for-byte identical. So moving Wasm builds to ARM64 is not a porting exercise for your code; it is only a question of whether every tool in the pipeline runs natively on ARM64, and whether any step silently falls back to emulation.

Most of the ecosystem does. Rust and its wasm32 targets, Emscripten’s emsdk, Binaryen, wasm-tools, Node and the common bundlers all ship native ARM64 builds for Linux and macOS. Gaps tend to appear in less central tools — an older prebuilt CLI that only publishes x86-64 binaries, a Docker base image without an ARM64 variant, a test browser that is x86-only in some CI images — and those gaps show up as either a hard failure or, worse, a slow build running under emulation.

ARM64 availability of common Wasm build tools Rust, emsdk, Binaryen, wasm-tools and Node provide native ARM64 builds on Linux and macOS. wasm-bindgen-cli and wasm-pack can be compiled from source or installed as prebuilt ARM64 binaries in recent versions. Docker images need multi-architecture tags. Headless browsers need ARM64 builds of the chosen browser. tool ARM64 Linux ARM64 macOS Rust + wasm32 targets native native emsdk (Emscripten) native native Binaryen / wasm-tools native native wasm-bindgen-cli source or prebuilt source or prebuilt Docker base images need arm64 tag via Docker Desktop

Step 1 — inventory every tool and image

List each executable the build runs and where it comes from: rustup toolchains, cargo installed CLIs, npm packages with native binaries (esbuild, swc, some image tools), downloaded release binaries, Docker images. For each, confirm an ARM64 build exists for your platforms. On a Linux ARM64 machine, run the build and check every downloaded binary with file:

file ~/.cargo/bin/wasm-bindgen ~/.cargo/bin/wasm-opt node_modules/@esbuild/*/bin/esbuild
# … ELF 64-bit LSB executable, ARM aarch64 …     ← native
# … ELF 64-bit LSB executable, x86-64 …          ← will fail or run under emulation

Step 2 — prefer installers that pick the architecture

Use installation methods that select the right architecture automatically: rustup for Rust, the emsdk installer for Emscripten, npm for packages with optional platform-specific dependencies, and cargo binstall or cargo install for Rust CLIs (the latter compiles from source and therefore always matches). Avoid hard-coded download URLs containing x86_64 in scripts; parametrise them with uname -m or the CI runner’s architecture variable.

Step 3 — use multi-architecture Docker images

If builds run in containers, build or pick images with both linux/amd64 and linux/arm64 variants:

docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/acme/wasm-builder:1.4 --push .

On an ARM64 runner, Docker then pulls the native variant. Without an ARM64 variant, Docker runs the amd64 image under QEMU emulation, which works but can be five to ten times slower — a common cause of mysteriously slow ARM builds. The container setup is covered in building Wasm in Docker for consistent output.

Native ARM64 versus emulated builds A native ARM64 toolchain and image run at full speed and produce identical Wasm output. An amd64-only image on an ARM64 runner runs under QEMU emulation, also producing identical output but several times slower, which is easy to miss without checking architectures. native ARM64 tools every binary is aarch64 full speed identical .wasm output the goal emulated amd64 QEMU or Rosetta translation 2–10× slower output still identical hidden cost

Step 4 — add an ARM64 job and compare outputs

Add an ARM64 job alongside the x86-64 one — GitHub Actions offers ubuntu-24.04-arm runners and macos-14 (Apple Silicon) — and compare the produced module’s hash with the x86-64 job’s:

jobs:
  build:
    strategy:
      matrix: { runner: [ubuntu-24.04, ubuntu-24.04-arm] }
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/build-wasm.sh
      - run: sha256sum dist/app_bg.wasm | tee hash-${{ matrix.runner }}.txt
      - uses: actions/upload-artifact@v4
        with: { name: hash-${{ matrix.runner }}, path: hash-*.txt }

A follow-up job downloads both hash files and fails if they differ. Identical hashes prove that the ARM64 build can replace the x86-64 one; differing hashes usually come from embedded paths or different tool versions, fixed as in producing reproducible Wasm binaries.

Step 5 — decide where to build

Once outputs match, choose runners on cost and speed. ARM64 CI runners are often cheaper per minute, and Rust compilation — the dominant cost for Rust Wasm builds — performs well on modern ARM cores. Keep one architecture as the release builder and the other for verification, or retire the slower one. Developers on Apple Silicon get the same toolchain natively, which removes the Rosetta translation that used to slow local builds.

Browser tests on ARM64

Building on ARM64 is straightforward; testing can need more care. Playwright provides ARM64 builds of Chromium and Firefox for Linux, and WebKit on macOS; some older CI images and some browser versions lack Linux ARM64 builds, in which case the test job falls back to x86-64 runners or emulation. Since WebAssembly behaviour does not depend on the build machine’s architecture, testing on one architecture is sufficient for correctness — but performance tests should run on the hardware users have, and many users browse on ARM devices (phones, Apple Silicon Macs, ARM Windows laptops). An ARM64 runner can therefore be a better place for performance regression tests than an x86 server, as long as results are compared only within the same runner type.

Apple Silicon development machines

On Apple Silicon Macs, install toolchains natively rather than through Rosetta: check that the terminal is not running under Rosetta (uname -m prints arm64), that Homebrew is the ARM64 installation in /opt/homebrew, and that Node is the ARM64 build. A mix of native and translated tools works but slows builds and occasionally causes confusing errors when native npm modules were installed for the wrong architecture. Docker Desktop on Apple Silicon runs ARM64 Linux containers natively and amd64 containers through emulation, so the multi-architecture images from step 3 matter for local builds too.

Caches per architecture

CI caches hold architecture-specific content even for Wasm projects: the Cargo target directory contains compiled build scripts and proc-macros for the host, node_modules contains platform binaries, and toolchain caches contain native executables. Restoring an x86-64 cache on an ARM64 runner either fails or, worse, places wrong-architecture binaries on the path. Include the runner architecture in every cache key — runner.arch in GitHub Actions — so each architecture has its own caches. The Wasm outputs themselves are architecture-independent and can be shared as artefacts between jobs, but the caches that produce them cannot. The general caching setup is described in caching Rust Wasm builds in GitHub Actions.

Cost and capacity planning

Runner pricing varies by provider and changes over time, so measure rather than assume. Record build minutes per job on each architecture for a few weeks, multiply by the per-minute price, and include queue times — a cheaper runner type that waits ten minutes for capacity is not cheaper for developers. For self-hosted runners, ARM64 cloud instances often offer more cores per dollar, which helps Rust’s parallel compilation. Keep the decision reversible: with identical outputs proven by hash comparison, moving the release build between architectures is a one-line change.

Expected output

The ARM64 CI job builds the module in 3 min 10 s against 4 min 40 s on x86-64, every tool reports aarch64, and the module’s SHA-256 is identical on both architectures, so either job can produce the release artefact.

Gotchas

  • Hard-coded x86-64 download URLs. They fail or run emulated. Select the architecture dynamically.
  • amd64-only Docker images. QEMU emulation hides behind slow builds. Publish multi-arch images.
  • npm native binaries installed on another architecture. Reinstall node_modules per architecture.
  • Assuming identical output. Verify hashes before switching release builders.
  • Rosetta terminals on Apple Silicon. Tools install for x86-64. Check uname -m.
  • Sharing caches across architectures. Native binaries in caches break the other runner. Key caches by architecture.

Performance note

For a mid-sized Rust Wasm project, a clean release build took 4 min 40 s on a 4-vCPU x86-64 runner, 3 min 10 s on a 4-vCPU ARM64 runner, and 18 min when the amd64 builder image ran under emulation on the ARM64 runner.

Clean Rust Wasm release build by runner Minutes for the same clean release build on a 4-vCPU x86-64 runner, a 4-vCPU ARM64 runner with native tools, and an ARM64 runner using an amd64-only container under emulation. minutes per build x86-64 runner 4.7 min ARM64 runner, native 3.2 min ARM64, emulated amd64 image 18 min

Frequently Asked Questions

Will an ARM64-built module run on x86 browsers? Yes. WebAssembly is architecture-independent; the build machine does not affect where it runs.

Does Emscripten work on Linux ARM64? Yes — emsdk provides native ARM64 Linux builds of its LLVM, Binaryen and Node.

Are Windows on ARM runners usable? Rust and Node support them; check each additional tool, since Windows ARM64 binaries are less universal.

Why do hashes differ between architectures? Usually different tool versions or embedded paths, not the architecture itself.

Can caches be shared between ARM64 and x86-64 jobs? Not build caches — they contain native binaries. Include the architecture in cache keys; share only the Wasm outputs.

Should developers on Apple Silicon use Docker for builds? Only with ARM64 images; native toolchains are simpler and faster when versions are pinned.

← Back to Cross-Platform Build Automation