Choosing Feature Levels for a Wasm Build
This page answers one task: your toolchain offers a list of WebAssembly features to enable — bulk memory, SIMD, threads, reference types, exception handling, tail calls, GC and more — and each choice affects size, speed and which browsers can run the module. You want a principled way to choose, based on what each feature buys you and who your users are.
Prerequisites
- [ ] Analytics or requirements describing target browsers and runtimes (versions, devices, server runtimes).
- [ ] Your toolchain’s feature flags (Rust
target-feature, Emscripten settings, Binaryen--enable-*). - [ ] A benchmark and size measurement for your module.
Features differ in what they buy
Post-MVP features fall into a few groups by benefit. Some are small, broadly supported improvements that toolchains now enable by default — sign extension, mutable globals, non-trapping float-to-int conversion, bulk memory, multi-value, reference types — and they make code a little smaller and faster with essentially universal support in current browsers. Some unlock significant performance for specific workloads — SIMD for numeric and media code, threads for parallel work (which additionally need cross-origin isolation in browsers). Some change how languages compile — exception handling for C++ and other languages with exceptions (replacing slow JavaScript-based emulation), tail calls for functional languages, GC for managed languages like Kotlin, Dart or OCaml. And some are newer still, with narrower support — memory64, relaxed SIMD, JS string builtins, JSPI.
Choosing a feature means accepting that engines without it cannot run the module at all. So the question per feature is: does what it buys justify excluding (or building a fallback for) the users whose engines lack it?
Step 1 — list what your users run
From analytics, compute the share of sessions (or of feature usage) per browser version, then map each version to the features it supports using a compatibility table. For server-side or edge runtimes, check each runtime’s supported features and whether they are on by default. The result is, per feature, the share of your users who could not run a module that uses it.
Step 2 — measure what each feature buys
Build variants and measure: size (compressed), startup, and benchmark speed. Typical results: baseline improvements change size and speed by a few percent; SIMD
can double or quadruple speed for vectorisable kernels and does nothing for branchy code; native exception handling can shrink C++ modules and speed up code
inside try regions substantially compared with JavaScript-based exceptions; threads scale parallel work with core count. Without measurement, it is easy to
enable a feature with no benefit or skip one that would matter.
Step 3 — group features into levels
Rather than deciding feature by feature for every build, define levels:
level "baseline": MVP + features supported by all browsers you support (often: sign-ext, mutable-globals, nontrapping-fptoint, bulk-memory)
level "modern": baseline + SIMD + reference types + multi-value (+ exception handling if your code needs it)
level "threads": modern + atomics/shared memory (served only to cross-origin isolated pages)
Each level is a set of toolchain flags applied consistently: compiler target features, standard-library build, Binaryen --enable-* flags and the
JavaScript glue’s target syntax. One CI job builds each level.
Step 4 — decide between one build and several
If the “modern” level is supported by nearly all your users (for example above 99% of sessions), ship one build and give the remainder a fallback message or server-side path. If a significant share would be excluded, ship two levels and choose at load time with feature detection. Threads almost always need a separate build, because they depend on page headers as well as engine support. Every extra level costs CI time, testing and maintenance, so keep their number small.
Step 5 — document and revisit
Record the levels, their flags, the support data and measurements behind them, and the date. Revisit every few months: as old engines disappear, raise the baseline; as new features ship broadly (GC, tail calls, relaxed SIMD), consider moving them into “modern” if they help your code.
Toolchain defaults change
Compilers move their defaults as features become widespread — recent LLVM versions enable several post-MVP features by default for wasm32 — so upgrading the
toolchain can silently raise your module’s requirements. Pin explicit target features in your build configuration rather than relying on defaults, and validate
each build’s feature use in CI (for example wasm-tools validate with a restricted feature set) so a toolchain upgrade cannot quietly exclude users.
Features that interact
Some features only make sense together or change each other’s value. Threads require bulk memory and atomics, and shared memories need the page to be cross-origin isolated — a deployment decision, not just a build flag. Exception handling interacts with how a toolchain lowers C++ and with whether the JavaScript glue expects JavaScript-based exceptions; mixing objects compiled with different exception modes fails at link time. Reference types change how wasm-bindgen generates glue, so enabling them affects JavaScript size as well as the module. SIMD and relaxed SIMD differ in determinism: relaxed SIMD allows results that vary slightly between hardware, which may matter for code that must produce identical output everywhere. When defining levels, list these dependencies explicitly so a level is internally consistent, and test each level end to end rather than assuming that features which work separately also work together.
Libraries and dependencies
Your feature level must hold for every dependency linked into the module. Precompiled libraries — a C library built once with SIMD, a Rust crate that enables
simd128 code paths through cfg(target_feature) — follow whatever flags they were built with. Rebuild dependencies with your level’s flags, and validate the
final linked module, not just your own code. For prebuilt Wasm libraries from third parties, ask which features they require; a single SIMD instruction in a
dependency is enough to make the baseline build fail on engines without SIMD.
Server and edge runtimes
On servers you control the runtime, so feature levels matter less — enable what the runtime supports and benefits your code. Edge platforms are in between: the platform chooses the engine version, usually current V8, and documents supported features. Check those documents before enabling newer features for code that runs both in browsers and at the edge.
Expected output
A documented feature-level policy: “baseline” (MVP plus four universally supported features) for under 2% of sessions on old engines, “modern” adding SIMD, reference types and native exceptions for everyone else, and a “threads” variant for isolated pages; CI builds and validates all three; size and speed measurements justify each feature; and a quarterly review is scheduled.
Gotchas
- Relying on toolchain defaults. Upgrades change them. Pin features explicitly.
- Enabling features without measuring. No benefit, fewer users. Measure.
- Too many levels. Testing burden grows. Keep two or three.
- Inconsistent flags across compiler, std and wasm-opt. Features leak in. Apply levels everywhere.
- Threads without isolation headers. The build cannot run. Serve it only to isolated pages.
- Prebuilt dependencies with newer features. One instruction breaks the baseline. Validate the linked module.
- Levels that are inconsistent internally. Features depend on each other. List dependencies per level.
Performance note
For an image-processing module, the “modern” level was 9% smaller and 2.4× faster than “baseline” on SIMD-friendly kernels; for a parser-heavy module, the difference was 4% smaller and 6% faster — enough to justify one modern build with a fallback message rather than two full levels.
Frequently Asked Questions
Is there an official list of “feature levels”? Not a standard one; define levels for your support matrix. Runtimes and toolchains document their supported features.
Do server runtimes need levels? Usually not — you control the runtime version, so enable what it supports.
Can Binaryen lower newer features? Some (for example via lowering passes for certain features); it cannot lower everything, such as SIMD in general.
Should GC languages consider fallback builds? Their output depends on GC; older engines need a different compilation target (often JavaScript) rather than a lower Wasm level.
Do dependencies have to follow my feature level? Yes — every linked library must; rebuild them with your flags and validate the final module.
Is relaxed SIMD safe for code needing identical results? Not necessarily — it allows hardware-dependent results; use standard SIMD where determinism matters.
Which features need deployment changes, not just flags? Threads — pages must be cross-origin isolated with COOP and COEP headers for shared memory.
How do edge platforms fit into feature levels? They choose the engine version; check their documented features before enabling newer ones in shared code.
Related
- Supporting older Safari versions with Wasm — a common constraint.
- Serving different Wasm builds per browser — loading levels.
- Detecting proposal support at runtime — detection.
- Shipping SIMD and baseline builds together — two builds in practice.
← Back to Polyfill Alternatives & Fallbacks