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
Compile a probe, catch the error A minimal module using one feature is compiled. Success means the engine supports the feature; a CompileError means it does not. The result is cached for the session and used to choose a build. 48-byte probe uses one feature compiles → supported throws → not supported cache, choose a build, report once per session The whole check is under a millisecond for a handful of features, which is why it belongs at startup rather than behind a lazy path.

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.

What the distribution usually looks like Most sessions support SIMD, a smaller share also achieves cross-origin isolation for threads, and a small tail supports neither. The tail is the reason the baseline build exists. SIMD only 71% SIMD + threads 24% baseline 5% Five percent is small and is not zero — and it is the share that would see a broken feature if the baseline build were dropped. Watch it over months; when it approaches zero, retiring the baseline becomes a decision rather than a gamble.

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.

Probe, then choose Each feature is detected by compiling a tiny binary that uses only that feature. Compilation succeeding is the test; the probe is never instantiated or run. probe bytes a few dozen bytes validate() compile succeeds or not feature set a small object of flags chosen build the best supported one Run every probe once at startup and cache the result; each is microseconds, but a per-call probe is not free. Probe the feature, never the user agent — engines ship features independently of version numbers. Always keep a baseline build as the last fallback, or an older engine gets a blank page.

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.

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