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.
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.
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.
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.
Related
- Choosing feature levels for a Wasm build — defining variants.
- Feature-detecting Wasm at startup — detection details.
- Shipping SIMD and baseline builds together — a two-variant setup.
- Testing fallback paths in CI — testing.
← Back to Polyfill Alternatives & Fallbacks