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.

A Bazel graph with native, Wasm and JavaScript targets The same Rust library is built for the host platform for the server binary and, through a platform transition, for wasm32 for the browser. wasm-bindgen generates glue, and the JavaScript bundle target depends on the generated package. Every node is cached by its inputs. rust_library core shared source host platform native server wasm32 transition rust_shared_library rust_wasm_ bindgen JS glue + .wasm JS bundle target imports the package

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.

Bazel rules for each Wasm toolchain Rust modules use rules_rust with a wasm32 platform and rust_wasm_bindgen for glue. C and C++ use the emsdk Bazel rules with wasm_cc_binary. WASI targets use rules_rust's wasi platforms or a wasi-sdk toolchain. JavaScript bundles consume the outputs through ordinary data dependencies. source rule output Rust (browser) rust_shared_library + rust_wasm_bindgen .wasm + JS glue C / C++ cc_binary + wasm_cc_binary Emscripten .wasm + .js Rust (WASI) rust_binary on a wasi platform WASI module JS app bundler rule with data deps bundled assets

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 local wasm-bindgen or emcc breaks hermeticity and caching. Use rule-provided toolchains.
  • CLI and crate version mismatch. Keep wasm-bindgen versions aligned between crate_universe and 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-opt so 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.

Build times in a Bazel monorepo with Wasm targets Time to build the web application including Rust and C++ Wasm modules from clean, after a TypeScript-only change, and on a fresh CI machine with a warm remote cache. seconds clean build 360 s TypeScript-only change 14 s fresh CI, warm remote cache 40 s

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.

← Back to Cross-Platform Build Automation