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.
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.
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_modulesper 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.
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.
Related
- Pinning Wasm toolchain versions — same versions on every runner.
- Setting up Wasm toolchains on Windows — another platform’s quirks.
- Building Wasm with Nix — multi-architecture toolchains.
- Benchmarking Wasm on mobile devices — ARM where users are.
← Back to Cross-Platform Build Automation