Shipping new Wasm features without leaving browsers behind

WebAssembly 1.0 shipped four years ago, but the feature set has kept growing through the proposal standardization process. Engines implement these proposals at different speeds, so if your code depends on a new instruction or API, you need a strategy that still works for users on browsers that haven't caught up yet.

Some proposals shrink code size by adding instructions for common patterns, others add performance-oriented primitives, and a few improve integration with the surrounding web platform. The full list of proposals with their current stages lives in the official repo, and engine support is tracked on the feature roadmap page.

The general approach has four steps: decide which features you actually want, group them by the browser support each one requires, compile your code once per group, and then serve the right bundle by detecting features at runtime.

Choosing features and forming cohorts

As a concrete example, say you want SIMD, threads, and exception handling for performance and code-size reasons. Across the major engines, browser support looks like this:

A table showing browser support of the chosen features.
View this feature table on webassembly.org/roadmap.

That support matrix suggests four distinct loads for the latest browser versions:

  • Chrome-based browsers get threads, SIMD, and exception handling.
  • Firefox gets threads and SIMD, but falls back for exception handling.
  • Safari gets threads only, falling back for SIMD and exception handling.
  • Everything else gets the baseline WebAssembly bundle.

This splits by feature support in current releases. Because modern browsers auto-update, worrying only about the latest version is usually safe. The baseline cohort also catches users stuck on older browsers, so the app still works for them, just without the optimizations.

Building one module per feature set

Wasm modules have no runtime capability negotiation: if an instruction appears in the module, the engine has to understand it. So you have to compile separate binaries, one for each feature cohort.

Each toolchain handles this differently, so the exact flags depend on your compiler. A single-file C++ library compiled with Emscripten, for example, needs the appropriate flags for SSE2-emulated SIMD, Pthreads-based threading, and your choice between Wasm exception handling and the fallback JavaScript implementation:

# First bundle: threads + SIMD + Wasm exceptions
$ emcc main.cpp -o main.threads-simd-exceptions.mjs -pthread -msimd128 -msse2 -fwasm-exceptions
# Second bundle: threads + SIMD + JS exceptions fallback
$ emcc main.cpp -o main.threads-simd.mjs -pthread -msimd128 -msse2 -fexceptions
# Third bundle: threads + JS exception fallback
$ emcc main.cpp -o main.threads.mjs -pthread -fexceptions
# Fourth bundle: basic Wasm with JS exceptions fallback
$ emcc main.cpp -o main.basic.mjs -fexceptions

The C++ source then uses compile-time guards like #ifdef __EMSCRIPTEN_PTHREADS__ and #ifdef __SSE2__ to select the parallel or vectorized implementations and drop back to serial code when those features are off:

void process_data(std::vector<int>& some_input) {
#ifdef __EMSCRIPTEN_PTHREADS__
#ifdef __SSE2__
  // …implementation using threads and SIMD for max speed
#else
  // …implementation using threads but not SIMD
#endif
#else
  // …fallback implementation for browsers without those features
#endif
}

Exception handling does not require preprocessor logic: C++ code uses the same try/catch syntax whether the compiler targets Wasm exception handling or the JavaScript fallback.

Choosing the right bundle in JavaScript

With the cohorts compiled, the application shell needs to figure out which bundle to fetch. The wasm-feature-detect library performs those checks, and combining it with dynamic import selects the right module stream:

import { simd, threads, exceptions } from 'https://unpkg.com/wasm-feature-detect?module';

let initModule;
if (await threads()) {
  if (await simd()) {
    if (await exceptions()) {
      initModule = import('./main.threads-simd-exceptions.mjs');
    } else {
      initModule = import('./main.threads-simd.mjs');
    }
  } else {
    initModule = import('./main.threads.mjs');
  }
} else {
  initModule = import('./main.basic.mjs');
}

const Module = await initModule();
// now you can use `Module` Emscripten object like you normally would

As the feature list grows, the number of cohorts can become unwieldy. In practice, it is acceptable to define cohorts around real user data and let less common browsers fall into the closest (or a baseline) group, as long as the app remains functional for them. That gives a workable line between progressive enhancement and runtime efficiency.

Built-in, in-module capability detection and feature-gated implementations would be a cleaner future solution, but any such mechanism would itself be a post-MVP feature that you would need to polyfill or detect. Until that lands, compiling cohorts and switching bundles in JavaScript is the only way to use new Wasm features and still cover every browser.