Detecting Proposal Support at Runtime
This guide answers one task: determine, in the page, which WebAssembly features the current engine supports — and use the answer to load the right build without guessing from a user-agent string.
Prerequisites
- [ ] A page that loads a module conditionally.
- [ ] At least two builds, or a plan to make one.
- [ ] Somewhere to report the result, so you learn your real distribution.
- [ ] Five minutes; the technique is short.
How a probe works
A feature probe is a minimal WebAssembly module that uses exactly one feature and nothing else. If the engine can compile it, the feature is supported; if compilation throws, it is not.
function supports(bytes) {
try {
new WebAssembly.Module(Uint8Array.from(bytes));
return true;
} catch {
return false;
}
}
Synchronous compilation is appropriate here because the modules are tiny — a few dozen bytes — and well
inside the size limit for synchronous compilation on the main thread. A probe that needed
WebAssembly.compile would make the whole check asynchronous for no benefit.
The probes themselves are byte arrays. Writing them by hand is possible and unpleasant; generating them
from WAT with wat2wasm and committing the bytes is the maintainable approach.
# the SIMD probe, as an example
cat > simd.wat <<'WAT'
(module (func (result v128) (v128.const i32x4 0 0 0 0)))
WAT
wat2wasm simd.wat -o simd.wasm
xxd -i simd.wasm | head -3
Use the library
Maintaining a set of probe modules by hand means regenerating them whenever a proposal’s encoding changes,
which it does during standardisation. wasm-feature-detect maintains them for you.
npm i wasm-feature-detect
import {
simd, threads, tailCall, exceptions, gc, memory64, bulkMemory, referenceTypes,
} from 'wasm-feature-detect';
export async function detectCapabilities() {
const [hasSimd, hasThreads, hasTailCall, hasExceptions] = await Promise.all([
simd(), threads(), tailCall(), exceptions(),
]);
return { hasSimd, hasThreads, hasTailCall, hasExceptions };
}
Note that threads() checks for SharedArrayBuffer and the threading instructions together, which is the
right question — a page without cross-origin isolation has the instructions and not the shared memory,
and a threaded build fails at instantiation rather than at compilation.
Detection is not the same as validation
A subtlety worth being precise about: a probe tells you the engine can compile an instruction, not that your module will work.
A module using threads compiles wherever the instructions exist, and fails at instantiation if
SharedArrayBuffer is unavailable — a different error, at a different time, for a different reason. A
module using memory64 compiles and then fails to allocate if the browser will not give it the memory. And
a module using an interface the host does not provide fails at instantiation with a LinkError, which no
feature probe predicts.
So the full check for a threaded build is three things, in order:
const ok = (await threads()) // instructions exist
&& typeof SharedArrayBuffer !== 'undefined' // shared memory available
&& crossOriginIsolated; // and the context permits it
Each of those can be true without the others, and each failure presents differently. Checking all three before selecting a threaded build turns three confusing runtime errors into one clear decision.
The same applies to any feature whose use depends on a host capability rather than only on the engine’s instruction set — which, as the ecosystem adds more host interfaces, is an increasing share of them.
Cache the answer, and do not persist it
Compiling probes is cheap and not free, and the answer cannot change within a page’s lifetime. Cache it in a module-level promise.
let capsPromise = null;
export function capabilities() {
capsPromise ??= detectCapabilities();
return capsPromise;
}
Do not persist it to localStorage. Browsers update, features arrive, and a cached false from three
months ago makes a user load the baseline build forever. The in-memory cache gives all of the benefit with
none of the staleness.
Choose the build
With the capabilities known, selecting a build is a lookup. Keep the mapping explicit rather than constructing a filename from flags, so it is readable and so an unexpected combination falls back safely.
const BUILDS = [
{ needs: (c) => c.hasThreads && c.hasSimd, url: '/assets/engine.threaded-simd.a91c3f.wasm' },
{ needs: (c) => c.hasSimd, url: '/assets/engine.simd.a91c3f.wasm' },
{ needs: () => true, url: '/assets/engine.baseline.a91c3f.wasm' },
];
export async function moduleUrl() {
const caps = await capabilities();
return BUILDS.find((b) => b.needs(caps)).url;
}
The final entry with () => true is the important one: whatever the capability combination, something
loads. A selection that can return nothing produces a page that works on the machines you tested and fails
silently elsewhere.
Report what you find
The detection result is also data, and it is the only reliable way to know your real audience rather than your assumed one.
const caps = await capabilities();
report({
metric: 'wasm-capabilities',
...caps,
crossOriginIsolated,
build: await moduleUrl(),
});
Aggregate by build. What teams usually discover is that cross-origin isolation fails more often than expected — embedded contexts, in-app browsers, enterprise proxies — and that a proposal they assumed was universal is missing for a small but real fraction. Both change decisions, and neither is visible without the report.
Keeping the probe set current
Probe modules encode a specific instruction sequence, and the encoding of a proposal can change while it
is at phase 3. A hand-maintained probe can therefore report false for a feature the engine supports,
because the probe is written against an older encoding.
That is the main argument for using a maintained library rather than a local copy: the library is updated when an encoding changes, and updating a dependency is easier than noticing that a probe has quietly gone stale.
If you do maintain your own — because you need a feature the library does not cover, or because you cannot take the dependency — pin the probes to a comment naming the proposal revision they were generated from, and regenerate them when the toolchain updates.
// probe generated from proposal revision 2026-04, wat2wasm 1.0.36
// (module (func (result funcref) (ref.null func)))
const REFERENCE_TYPES = [0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00, /* … */];
A test that compiles each probe against a current engine and asserts the expected result catches a stale probe at build time, which is considerably better than discovering it from a support report that says a modern browser lacks a feature it has had for two years.
Expected output
A startup log that names the capabilities and the chosen build makes every later question easier:
wasm capabilities: { simd: true, threads: false, tailCall: true, exceptions: true }
crossOriginIsolated: false
selected build: /assets/engine.simd.a91c3f.wasm
module ready in 62 ms
# an isolated context, on the same machine
wasm capabilities: { simd: true, threads: true, tailCall: true, exceptions: true }
crossOriginIsolated: true
selected build: /assets/engine.threaded-simd.a91c3f.wasm
module ready in 71 ms
Seeing those two lines side by side is usually the moment a team realises their production headers are not what they thought.
Gotchas
- Inferring support from the user agent. Wrong for flags, enterprise policies, in-app browsers and anything you have not seen.
- Persisting the result. A stale negative outlives the browser update that fixed it.
- Detecting per call. Compiling probes repeatedly is wasteful; cache in memory.
- No final fallback in the build table. An unanticipated combination selects nothing.
- Checking threading instructions without
SharedArrayBuffer. Compiles and then fails at instantiation; check both, which the library does. - Not reporting the result. You never learn the distribution, so the fallback can never be retired.
Performance note
Detecting eight features with wasm-feature-detect took 0.7 ms in total on a laptop and 2.4 ms on a
mid-range phone, all of it synchronous compilation of tiny modules. Running it at startup and caching the
promise means the cost is paid once per page and never appears again, which makes it cheap enough that
there is no argument for deferring it.
Frequently Asked Questions
Should I detect before or after loading the glue? Before. The detection decides which module URL the glue is given, so it belongs at the very start of the loading sequence — ideally overlapping the rest of the page’s initialisation.
What if a feature is supported but slow? That happens — an early implementation may be correct and unoptimised. Detection tells you what is available, not what is fast; a benchmark on first run, cached for the session, is the way to make a performance-based choice.
Is there a cost to probing features I do not use? A fraction of a millisecond each, and a little clarity in the telemetry. Probing a feature you might adopt next quarter is a cheap way to know in advance whether adopting it would be viable.
Can I detect at build time instead? No. The build has no idea what engine will run it, which is the entire reason this technique exists.
Related
- Detecting SIMD support at runtime — the same technique for the most common case.
- Shipping SIMD and baseline builds together — producing the builds this selects between.
- Feature detecting Wasm at startup — the check before any of these.
Detection is twenty lines and it replaces every assumption you would otherwise be making about your users.
← Back to Post-MVP Wasm Proposals in Practice