Building Wasm with Bazel
This page answers one task: a monorepo already builds with Bazel, and its Rust or C++ code must also produce WebAssembly modules — hermetically, cacheably, and as inputs to the JavaScript targets that bundle them.
Prerequisites
- [ ] A Bazel workspace using Bzlmod (
MODULE.bazel), Bazel 7 or newer. - [ ] Rust or C/C++ code that already builds natively with Bazel.
- [ ] Familiarity with Bazel platforms, toolchains and transitions.
Why Bazel for Wasm
Bazel’s strengths — hermetic toolchains, fine-grained caching and remote execution — matter for WebAssembly builds that sit inside larger systems. A typical monorepo has a Rust core used by a native server and compiled to Wasm for the browser, C++ libraries compiled to Wasm with Emscripten, and JavaScript applications that import the results. Building all of that with one graph means a change to the Rust core rebuilds exactly the native binary, the Wasm module and the JavaScript bundle that depend on it, and every developer and CI machine shares cached results.
The key concept is the platform. Bazel builds each target for a configured platform; a Wasm module is the same source built for a wasm32 platform
instead of the host. Rules provide toolchains for that platform — rules_rust for Rust, the emsdk Bazel rules for C and C++ — and transitions let
one target (for example a JavaScript bundle) depend on another built for a different platform.
Step 1 — add the rules
In MODULE.bazel, add rules_rust and register a toolchain with the wasm32-unknown-unknown target, and the emsdk rules if you build C or C++:
bazel_dep(name = "rules_rust", version = "0.56.0")
rust = use_extension("@rules_rust//rust:extensions.bzl", "rust")
rust.toolchain(
edition = "2021",
versions = ["1.83.0"],
extra_target_triples = ["wasm32-unknown-unknown"],
)
use_repo(rust, "rust_toolchains")
register_toolchains("@rust_toolchains//:all")
bazel_dep(name = "emsdk", version = "3.1.73") # Emscripten toolchain for C/C++
Pin versions here exactly as you would elsewhere; Bazel makes the pin hermetic, so every machine uses the same compiler.
Step 2 — build a Rust Wasm module
Declare the library once, build a cdylib for the Wasm platform, and run wasm-bindgen with the rules_rust integration:
load("@rules_rust//rust:defs.bzl", "rust_library", "rust_shared_library")
load("@rules_rust//wasm_bindgen:defs.bzl", "rust_wasm_bindgen")
rust_library(name = "core", srcs = glob(["src/**/*.rs"]), deps = [...])
rust_shared_library(
name = "core_wasm_lib",
srcs = ["wasm/lib.rs"],
deps = [":core", "@crates//:wasm-bindgen"],
platform = "@rules_rust//rust/platform:wasm", # builds for wasm32-unknown-unknown
)
rust_wasm_bindgen(
name = "core_wasm",
wasm_file = ":core_wasm_lib",
target = "web",
)
bazel build //core:core_wasm produces the .wasm and JavaScript glue. The crate graph comes from crate_universe, generated from a Cargo.toml and
Cargo.lock, so wasm-bindgen’s crate version and the CLI version that rules_rust uses must match — the same constraint described in
fixing wasm-bindgen schema version mismatches.
Step 3 — build C and C++ with Emscripten
The emsdk rules provide a wasm_cc_binary that transitions a cc_binary to the Emscripten toolchain:
load("@emsdk//emscripten_toolchain:wasm_rules.bzl", "wasm_cc_binary")
cc_binary(
name = "codec",
srcs = ["codec.cc"],
linkopts = ["-sMODULARIZE", "-sEXPORT_ES6", "-sALLOW_MEMORY_GROWTH"],
)
wasm_cc_binary(name = "codec_wasm", cc_target = ":codec")
The same cc_library dependencies serve native and Wasm builds, each compiled for its own platform. Emscripten’s cache is handled by the rules; keep it
inside Bazel’s output tree rather than a user directory so builds stay hermetic.
Step 4 — feed outputs to JavaScript targets
JavaScript rules (rules_js, or a genrule running your bundler) depend on the Wasm targets like any other file. Because the Wasm target carries its
platform transition, the JavaScript target does not need to know it is depending on a different platform:
js_run_binary(
name = "app_bundle",
srcs = ["//web:sources", "//core:core_wasm", "//codecs:codec_wasm"],
tool = ":vite",
args = ["build"],
out_dirs = ["dist"],
)
Run wasm-opt as its own action between the module and the bundle, so the optimisation is cached separately from compilation and can be tuned without
recompiling.
Step 5 — cache and execute remotely
Wasm targets cache like everything else. With a remote cache, a developer who changes only TypeScript downloads the already-built .wasm instead of
compiling Rust. Remote execution works too, provided toolchains are hermetic — no reliance on a locally installed wasm-pack, emsdk or Binaryen. Audit
actions with bazel aquery to make sure no tool is taken from PATH.
Debug, release and size checks
Use Bazel’s compilation modes and configuration settings to produce debug and release variants: -c dbg for debug info and assertions, -c opt for
release with wasm-opt. Add a size check as a test target that fails when a module exceeds its budget, so size regressions show up in bazel test like any
other failure, as in
catching size regressions in CI.
Browser tests can run as Bazel tests with rules_js and a headless browser, keeping the whole verification graph in one tool.
When Bazel is the wrong tool
Bazel pays off in large monorepos with many languages and teams. For a single Rust crate and a JavaScript app, the configuration overhead — rules, crate
generation, platform transitions — is substantial compared with wasm-pack build and a bundler plugin. The rules for Wasm also move faster than core
Bazel, and upgrading them occasionally requires adapting build files. If the repository is not already on Bazel, a task runner with pinned toolchains, as
in
driving Wasm builds with a justfile,
or a reproducible container, is usually the better start.
Managing crates and their features for two platforms
A Rust crate graph built for both the host and wasm32 often needs different features per platform: getrandom with its JavaScript backend for Wasm,
tokio only for the server, web-sys only for the browser. With Cargo this is expressed through [target.'cfg(target_arch = "wasm32")'.dependencies], and
crate_universe translates those target-specific dependencies into Bazel select() statements keyed on the platform’s constraints. Make sure the
platforms you build for are listed in the crate_universe configuration’s supported platform triples, otherwise dependencies for wasm32 are silently
missing and builds fail with confusing unresolved-import errors. Cargo features that must differ per platform are easiest to handle by splitting crates —
a platform-independent core, a thin browser crate and a thin server crate — so each Bazel target has a single, explicit feature set.
Developer experience
Bazel’s caching makes builds fast, but iteration loops for Wasm also need fast feedback in the browser. Use ibazel (bazel-watcher) to rebuild the Wasm
target on file changes, and point the development server at Bazel’s output directory, or run the dev server itself as a Bazel target that depends on the
module. Keep rust-analyzer working by generating rust-project.json with rules_rust’s tooling, so editors understand the crate graph including wasm32-only
code. For C and C++, compile_commands.json generated from Bazel gives clangd the same view. Without these, developers fall back to Cargo or CMake
alongside Bazel, and the two drift apart.
Mixed-language modules
Some modules combine Rust and C in one binary — a Rust core calling a C library compiled for wasm32. Bazel handles this naturally when both rules can
target the same platform: the C library built with a wasm32 C toolchain links into the Rust rust_shared_library. The constraint is that both sides must
agree on the C ABI and the libc they use; mixing Emscripten-built objects with wasm32-unknown-unknown Rust objects usually fails, while wasi-sdk C
objects link with Rust’s WASI targets. The underlying linker considerations are covered in
linking C and Rust objects into one module.
Expected output
bazel build //web:app_bundle builds the Rust core for wasm32, generates glue, compiles the C++ codec with Emscripten, runs wasm-opt, and bundles the
application; a second machine with the remote cache builds the same target in seconds without compiling any Rust or C++.
Gotchas
- Tools from
PATH. A localwasm-bindgenoremccbreaks hermeticity and caching. Use rule-provided toolchains. - CLI and crate version mismatch. Keep
wasm-bindgenversions aligned betweencrate_universeand the rules. - Building Wasm for the host platform. Missing transitions produce native libraries. Check the output’s file type.
- Emscripten cache outside the sandbox. Breaks remote execution. Let the rules manage it.
- Optimising inside the compile action. Separate
wasm-optso it caches independently.
Performance note
In one monorepo, a clean build of the Rust core, its Wasm module and the web bundle took 6 minutes; an incremental TypeScript change rebuilt only the bundle in 14 seconds; and a fresh CI machine with a warm remote cache built everything in 40 seconds.
Frequently Asked Questions
Can Bazel run wasm-pack? It can, but rules_rust’s native wasm-bindgen support is more hermetic. wasm-pack downloads tools at build time, which conflicts with sandboxing.
Does remote execution work with Emscripten? Yes, with the emsdk rules managing the toolchain; avoid host-installed emsdk.
How do I build WASI targets?
Use rules_rust’s WASI platforms or a wasi-sdk C toolchain registered for a wasm32-wasip1 platform.
Can Bazel build the Component Model?
Rules for components and wasm-tools exist in the community; check their maturity before depending on them.
Why are my wasm32-only dependencies missing?
Add wasm32 to crate_universe’s supported platform triples so target-specific dependencies are generated for it.
Related
- Building Wasm with Nix — another hermetic approach.
- Pinning Wasm toolchain versions — version discipline.
- Structuring a monorepo with Rust Wasm and a JS app — without Bazel.
- Producing reproducible Wasm binaries — byte-identical output.
← Back to Cross-Platform Build Automation