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(orwasm32-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.
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.
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
wasmfeature that must be enabled manually. It will be forgotten. Usecfg(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.
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.
Related
- Choosing a Rust Wasm target triple — which target to compile for.
- Using std time and threads in Rust Wasm — APIs that differ by platform.
- Unit testing Rust Wasm with wasm-bindgen-test — testing the Wasm side.
- Sharing one Wasm core across web and desktop — the same split at product level.
← Back to Rust to Wasm Compilation Guide