Using Cargo Features for Wasm and Native Builds

This page answers one task: a Rust crate must compile both to WebAssembly (for the browser or WASI) and natively (for a server, a CLI or tests), but some dependencies and code only make sense on one side — and you want both builds to stay green without a tangle of conditional compilation.

Prerequisites

  • [ ] A Rust workspace with code shared between a Wasm target and a native target.
  • [ ] The wasm32-unknown-unknown (or wasm32-wasip1) target installed.
  • [ ] CI that can build several targets.

Two mechanisms, two purposes

Rust offers two tools for platform-specific code, and they answer different questions. cfg(target_arch = "wasm32") (and target_os, target_family) reflects what the code is being compiled for: it is decided by the target, automatically, and cannot be turned on or off by users. Cargo features reflect what functionality was requested: they are chosen by whoever depends on the crate, and Cargo unifies them across the dependency graph — if any crate in the build enables a feature, it is enabled for everyone.

Use cfg for facts about the platform: browser APIs exist only on wasm32-unknown-unknown, threads and the file system exist natively, getrandom needs a different backend in the browser. Use features for optional functionality: a serde integration, a wasm-bindgen binding layer, an optional SIMD path. Mixing them up causes most of the pain: a wasm feature that must be turned on for Wasm builds can be forgotten; a feature that enables browser-only dependencies breaks native builds when unification turns it on accidentally.

cfg attributes versus Cargo features cfg target attributes reflect the compilation target, are set automatically and cannot be toggled by dependents. Cargo features reflect requested functionality, are chosen by dependents and unified across the build. Platform facts belong in cfg; optional capabilities belong in features. cfg(target_arch = "wasm32") set by the target automatically cannot be forgotten or forced use for platform facts what you compile for Cargo features chosen by dependents unified across the graph use for optional capabilities what you asked for

Step 1 — declare target-specific dependencies

Dependencies that exist only for one platform go in target tables, so they are not even compiled elsewhere:

[dependencies]
serde = { version = "1", features = ["derive"] }

[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
getrandom = { version = "0.2", features = ["js"] }

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "fs"] }

cargo build --target wasm32-unknown-unknown resolves the first table; a native build resolves the second. No feature needs to be remembered.

Step 2 — gate code with cfg, not features

pub fn now_ms() -> f64 {
    #[cfg(target_arch = "wasm32")]
    { js_sys::Date::now() }
    #[cfg(not(target_arch = "wasm32"))]
    { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs_f64() * 1000.0 }
}

Keep cfg blocks small and push them to the edges — a handful of platform functions — so the bulk of the crate is plain Rust compiled identically on both targets. wasm32-unknown-unknown and WASI targets differ too: target_os = "unknown" versus target_os = "wasi" distinguishes browser-style builds from WASI builds when both are supported.

Step 3 — split the crate when cfg blocks multiply

When platform-specific code grows beyond a few functions, split the workspace: a core crate with no platform dependencies, a core-wasm crate with the #[wasm_bindgen] exports and browser glue, and a core-native crate or binary for the server or CLI. The core compiles everywhere and is tested natively with cargo test; each wrapper is thin. This structure also prevents a common accident — browser-only dependencies leaking into the server binary through feature unification. The workspace layout is described in structuring a monorepo with Rust Wasm and a JS app.

A workspace that builds for both targets A platform-independent core crate holds the logic and is tested natively. A thin wasm wrapper crate adds wasm-bindgen exports and browser glue. A thin native crate adds the server or CLI. Target-specific dependencies live only in the wrappers, so neither side pulls in the other's dependencies. core-wasm (cdylib) #[wasm_bindgen] exports, js-sys core-native (bin/lib) tokio, fs, CLI core (lib) pure Rust, no platform deps shared dev-dependencies proptest, test fixtures CI matrix native tests + wasm32 build/tests

Step 4 — use features for genuinely optional functionality

Features remain the right tool for capabilities users opt into. Keep them additive — enabling a feature only adds functionality and never removes or changes behaviour — because unification means any dependent can turn it on:

[features]
default = []
serde = ["dep:serde"]
simd = []                 # enables explicit SIMD code paths where supported
#[cfg(all(feature = "simd", target_arch = "wasm32", target_feature = "simd128"))]
fn sum(xs: &[f32]) -> f32 { simd_sum(xs) }
#[cfg(not(all(feature = "simd", target_arch = "wasm32", target_feature = "simd128")))]
fn sum(xs: &[f32]) -> f32 { xs.iter().sum() }

Combining a feature with cfg(target_feature = "simd128") keeps the SIMD path off on targets without SIMD even when the feature is enabled.

Step 5 — build and test both in CI

Add a matrix that builds and tests each target, so a change that only compiles natively is caught immediately:

strategy:
  matrix:
    include:
      - { target: x86_64-unknown-linux-gnu, test: "cargo test" }
      - { target: wasm32-unknown-unknown, test: "wasm-pack test --headless --chrome crates/core-wasm" }
      - { target: wasm32-wasip1, test: "cargo test --target wasm32-wasip1" }      # with a wasmtime runner configured
steps:
  - run: rustup target add ${{ matrix.target }}
  - run: cargo build --workspace --target ${{ matrix.target }}
  - run: ${{ matrix.test }}

cargo hack --each-feature additionally builds every feature on its own, catching features that fail in isolation.

Feature unification pitfalls

Cargo unifies features per build, and before resolver version 2 it unified them even across targets and between normal and build dependencies — so a native-only dependency enabling a feature could enable it in the Wasm build too. Use resolver = "2" (the default for edition 2021 and later) in the workspace, which keeps target-specific and dev-dependency features separate. Even with resolver 2, building several workspace members together unifies their features: cargo build --workspace --target wasm32-unknown-unknown may enable features requested by the native crate. Build the Wasm crate on its own (-p core-wasm) for release artefacts, so only its feature set applies. When a feature causes surprises, cargo tree -e features -i <crate> shows which dependent enabled it.

Dependencies that do not support Wasm

Some crates fail to compile for wasm32 at all — they use threads, the file system, sockets or C libraries unconditionally. Target-specific dependency tables keep them out of Wasm builds, but if the core genuinely needs their functionality, look for alternatives with Wasm support or optional features that disable the unsupported parts (default-features = false). Diagnosing such failures is covered in fixing crates that fail to compile for Wasm.

Running the same tests on both targets

The core crate’s tests are most valuable when they run on both targets, because differences in integer widths, floating-point formatting, hashing and allocation behaviour occasionally surface only on wasm32. usize is 32 bits on wasm32 and 64 bits on most native hosts, so arithmetic that overflows only on one side, or serialisation that writes usize directly, behaves differently. Write tests in the core with plain #[test], run them natively with cargo test, and run the same tests on wasm32-wasip1 under Wasmtime by setting a runner in .cargo/config.toml:

[target.wasm32-wasip1]
runner = "wasmtime run --dir=."

Then cargo test -p core --target wasm32-wasip1 compiles the test binary to Wasm and runs it. Browser-only code in the wrapper crate still needs wasm-bindgen-test, but the logic in the core is covered on a 32-bit Wasm target without a browser, which catches most portability bugs cheaply.

Documenting the build matrix for contributors

A crate that builds for several targets confuses contributors who only ever run cargo build. State in the README which targets are supported, which commands build each one, which features exist and whether they are meant for both targets, and which crates in the workspace are platform-specific. Add an xtask or just recipe — just check-all — that runs the same matrix as CI locally, so contributors find a broken Wasm build before pushing rather than after a CI round trip. Rust-analyzer can be pointed at the Wasm target (rust-analyzer.cargo.target) when working in the wrapper crate, so editor diagnostics reflect the cfg branches that actually compile there.

Expected output

cargo build -p core-native and cargo build -p core-wasm --target wasm32-unknown-unknown both succeed; the native binary contains no wasm-bindgen or js-sys; the Wasm module contains no tokio; the core’s tests run natively in seconds; and CI fails any change that breaks either target.

Gotchas

  • A wasm feature that must be enabled manually. It will be forgotten. Use cfg(target_arch) instead.
  • Non-additive features. Unification can turn them on unexpectedly. Keep features additive.
  • Building the whole workspace for Wasm releases. Native members’ features leak in. Build the Wasm crate alone.
  • cfg blocks scattered everywhere. Push platform code to a few edge functions or wrapper crates.
  • Only testing natively. Wasm-specific code paths go untested. Run wasm32 tests in CI.
  • Assuming 64-bit usize. wasm32 has 32-bit pointers. Run the core’s tests on a Wasm target too.

Performance note

Splitting the crate into core and wrappers cut native test time from 48 s (which previously compiled browser dependencies) to 19 s, and removed 140 KB of unused dependency code from the native server binary. The Wasm module was unchanged.

Native test time before and after splitting the crate Seconds for the native test suite of a crate that previously compiled browser-only dependencies for every build, and after splitting into a core crate with thin wasm and native wrappers. seconds per native test run single crate with mixed deps 48 s core + thin wrappers 19 s

Frequently Asked Questions

Should I use cfg(target_family = "wasm")? It matches all Wasm targets; use target_arch = "wasm32" or target_os when you need to distinguish browser and WASI builds.

Can a feature select the Wasm target? No — targets are chosen on the command line. Features cannot change what you compile for.

How do I test browser-only code? With wasm-bindgen-test in a headless browser; see the testing guides.

What about build scripts? build.rs runs on the host; read CARGO_CFG_TARGET_ARCH to know the target being built.

Why does my code compile natively but fail on wasm32 with integer errors? usize is 32 bits on wasm32; casts and constants that assumed 64 bits overflow. Use explicit u64 where sizes can exceed 4 GB.

← Back to Rust to Wasm Compilation Guide