Serving Different Wasm Builds per Browser

This page answers one task: you build your WebAssembly module in two or three variants — for example a SIMD + threads build, a SIMD build, and a baseline build — and need each visitor to load exactly one: the best their browser supports. You want detection that is reliable, fast and future-proof, and a loading path that does not download variants it will not use.

Prerequisites

  • [ ] Several builds of the module at different feature levels, each with its own JavaScript glue if the toolchain generates glue.
  • [ ] Content-hashed URLs for each variant.
  • [ ] The list of features that distinguish the variants.

Detect features, not browsers

User-agent sniffing guesses features from browser names and versions; it breaks when browsers update, misidentifies embedded browsers and WebViews, and cannot see flags or enterprise policies. Feature detection asks the engine directly. For WebAssembly that is cheap and exact: WebAssembly.validate(bytes) returns whether the engine accepts a module, so a tiny module using one instruction from a feature — a SIMD operation, an atomic instruction, a try_table — tells you whether that feature is available. Validation of a few dozen bytes takes microseconds and needs no network request.

The wasm-feature-detect package wraps these probes as functions (simd(), threads(), exceptions(), gc(), and so on), each returning a promise of a boolean. Threads also need runtime conditions beyond engine support: crossOriginIsolated must be true for shared memory.

Choosing a build at load time The loader runs tiny validation probes for SIMD and threads and checks cross-origin isolation. If threads are available and the page is isolated, it loads the SIMD and threads build; otherwise if SIMD validates it loads the SIMD build; otherwise the baseline. Only the chosen variant and its glue are downloaded. probe features WebAssembly.validate check isolation crossOriginIsolated pick best variant threads > simd > baseline load its glue + .wasm one download instantiate report chosen variant

Step 1 — write the selection logic

import { simd, threads } from "wasm-feature-detect";

async function pickVariant() {
  const [hasSimd, hasThreads] = await Promise.all([simd(), threads()]);
  if (hasThreads && hasSimd && globalThis.crossOriginIsolated) return "simd-threads";
  if (hasSimd) return "simd";
  return "baseline";
}

const VARIANTS = {
  "simd-threads": () => import("./pkg-simd-threads/app.js"),
  simd: () => import("./pkg-simd/app.js"),
  baseline: () => import("./pkg-baseline/app.js"),
};

const variant = await pickVariant();
const mod = await VARIANTS[variant]();
await mod.default();                    // the variant's glue loads its own .wasm
report({ wasmVariant: variant });

Dynamic import() of each variant’s glue means bundlers emit separate chunks, and only the chosen chunk and its .wasm are downloaded.

Step 2 — keep glue and module paired

Each variant’s JavaScript glue must match its module exactly — wasm-bindgen and Emscripten glue are generated per build and are not interchangeable. Keep each variant in its own directory with its glue, and let the glue resolve its .wasm relative to import.meta.url. Mixing glue from one build with a module from another produces LinkErrors or subtle failures.

User-agent sniffing versus feature detection User-agent sniffing maps browser names and versions to assumed features, breaking with updates, WebViews and policies. Feature detection validates tiny probe modules in the actual engine, giving exact answers in microseconds that stay correct as browsers change. user-agent sniffing guesses from names/versions breaks on updates, WebViews needs constant maintenance avoid feature detection asks the engine directly microseconds, no network correct as browsers change use this

Step 3 — preload the right variant early

Detection runs in JavaScript, so the browser cannot preload the module from HTML before detection completes. To avoid a delay, run detection as early as possible (inline in the page’s first script) and immediately inject a <link rel="preload"> for the chosen variant’s .wasm and modulepreload for its glue. Remember the result in sessionStorage (wrapped in try/catch) so subsequent pages skip detection — the engine does not change within a session.

Step 4 — configure caching and CDNs

Each variant has its own content-hashed URL, so caching works per variant. Avoid serving variants from one URL with content negotiation based on request headers — browsers do not send feature information, and Vary on user agent fragments caches. If the CDN pre-warms or pushes assets, include all variants.

Step 5 — test every path

Every variant needs testing, including the fallback paths that most developers never see. Force each variant in tests (a query parameter or test hook that overrides detection), run the end-to-end suite once per variant, and include at least one real old browser for the baseline. Report the chosen variant in analytics so you know how many users run each, and when a variant can be retired.

Server-side alternative

When variants differ only in features that servers cannot detect, selection must happen in the client as above. Some sites use Client Hints for coarse device information, but they do not reveal WebAssembly features. Keep the decision in the client, where the engine can answer exactly.

Falling back when the chosen variant fails

Detection can be right and loading still fail: a network error, a CDN serving a stale or corrupted file, an engine bug triggered by a particular instruction sequence, or a memory limit on a low-end device that the higher variant hits and the lower does not. Make the loader resilient by trying variants in order: catch CompileError, LinkError and RangeError from the chosen variant, record the failure with the variant name and error, and try the next lower variant before giving up. Distinguish network failures (retry the same variant) from compile failures (move down a level). Report every fallback; a sudden rise in fallbacks from the SIMD variant after a release is an early signal that something in that build broke for a class of devices.

Writing your own probes

The wasm-feature-detect package covers common features, but you can write probes for anything the engine validates — a newer proposal your build uses, or a specific instruction you depend on. A probe is a minimal module that uses the instruction in a function; produce its bytes with wat2wasm once and embed them as a byte array:

// (module (func (result v128) (v128.const i32x4 0 0 0 0)))  — SIMD probe
const SIMD_PROBE = new Uint8Array([0,97,115,109,1,0,0,0,1,5,1,96,0,1,123,3,2,1,0,10,22,1,20,0,253,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,11]);
const hasSimd = WebAssembly.validate(SIMD_PROBE);

Keep probes in one module with tests that confirm each returns true in current browsers, so a mistyped byte does not silently send everyone to the baseline.

Server-rendered pages and the first request

On server-rendered pages, the HTML cannot know which variant the client will choose, so it cannot include a static preload for the right .wasm. The inline detection script described above closes most of that gap; alternatively, remember the variant in a cookie set by the client after the first detection, and have the server emit the matching preload on subsequent page loads. Treat the cookie as a hint only — the client still validates before loading, in case the browser changed.

Keeping variant builds in sync

All variants must come from the same source revision and be deployed together. Build them in one CI job, publish them atomically with the same release identifier, and have the loader refuse to mix variants from different releases.

Reporting and retiring variants

Report the selected variant and any fallbacks with every session. When a variant’s share stays below your threshold for a few months, remove it from the loader and the build, and simplify the test matrix accordingly.

Expected output

The loader detects SIMD and threads in under a millisecond, loads the simd-threads variant on isolated pages in current Chrome and Firefox, the simd variant in browsers where isolation is not available, and baseline on old engines; each variant’s glue and .wasm are separate hashed chunks; analytics show 88% simd-threads, 10% simd, 2% baseline; and CI runs the full test suite against each variant.

Gotchas

  • User-agent sniffing. Breaks with updates. Validate probes.
  • Threads without isolation. Engine support is not enough. Check crossOriginIsolated.
  • Mixing glue and modules across variants. Link errors. Pair them per directory.
  • Loading all variants. Wasted bandwidth. Import only the chosen one.
  • Untested fallbacks. Bugs reach the fewest, least visible users. Force each variant in CI.
  • Untested hand-written probes. A wrong byte sends everyone to the baseline. Test probes in current browsers.

Performance note

Running the probes took about 0.3 ms in total; the chosen variant’s download was the only Wasm request, compared with a naive approach that fetched the baseline first and then upgraded, which doubled transfer for most users.

Wasm bytes transferred per visit Kilobytes of Wasm transferred per first visit when downloading the baseline then upgrading to the SIMD build, and when detecting features first and downloading only the chosen variant. KB transferred (compressed) baseline then upgrade 1,840 KB detect first, one variant 960 KB

Frequently Asked Questions

Is WebAssembly.validate synchronous? Yes — and fast for tiny probes; wasm-feature-detect wraps it in promises.

Can a service worker choose the variant? It cannot detect page-level isolation easily; detection in the page is simpler.

How many variants are reasonable? Two or three; each adds build and test cost.

What if detection says yes but compilation fails? Catch the error, record it, and fall back to the next variant.

What should the loader do if the chosen variant fails to compile? Record the error and try the next lower variant; retry the same variant only for network errors.

How can the server preload the right variant? Remember the client’s detected variant in a cookie and emit the matching preload on later pages; the client still validates.

Can variants from different releases be mixed? No — build and deploy all variants together and have the loader refuse mismatched releases.

How do I know when a variant can be removed? When analytics show its share of sessions below your threshold for several months.

← Back to Polyfill Alternatives & Fallbacks