Detecting SIMD Support at Runtime

This guide shows how to determine, in a few hundred microseconds and without a network request, whether the current engine can run a module containing v128 instructions — and how to structure the check so the answer is available before you decide which binary to fetch.

The technique is the same one that works for every post-MVP feature: build the smallest possible module that uses the instruction in question, and hand it to WebAssembly.validate. Validation is synchronous, allocates almost nothing, and returns a boolean rather than throwing, so the whole check is a single expression.

The smallest question you can ask an engine The probe is a complete but trivial module: a header, one function type, one function whose body contains a single vector instruction. Validation stops at the first instruction it does not recognise, so the answer depends only on whether that opcode is supported. header 8 bytes, fixed type + function one signature, one body the instruction under test v128.const · i8x16.add validate → true / false no instantiation, no side effects The probe never runs — validation alone answers the question, which is why it costs so little and cannot fail in interesting ways. The same shape works for threads, bulk memory, exception handling and any other proposal: swap the instruction. What it cannot tell you is whether the feature is fast — only whether the engine will accept it.

Prerequisites

  • [ ] A page or worker where the check runs before the module fetch is decided
  • [ ] The probe bytes below, or a build step that assembles them from WAT
  • [ ] A baseline build to fall back to — detection without an alternative is not useful
  • [ ] wat2wasm if you prefer to generate the probe rather than paste it

Step-by-step procedure

1. Write the probe in WAT

(module
  (func (result v128)
    v128.const i32x4 0 0 0 0))

2. Assemble it and read out the bytes

wat2wasm --enable-simd probe.wat -o probe.wasm
xxd -i probe.wasm | head -8

3. Inline the bytes and validate

const SIMD_PROBE = new Uint8Array([
  0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,   // header
  0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7b,         // type: () -> v128
  0x03, 0x02, 0x01, 0x00,                           // function section
  0x0a, 0x0a, 0x01, 0x08, 0x00, 0xfd, 0x0c,         // code: v128.const
  0x00, 0x00, 0x00, 0x00, 0x0b,
]);

export const hasSimd = WebAssembly.validate(SIMD_PROBE);

The 0xfd prefix is the SIMD opcode space; an engine without the proposal rejects the module at that byte and validate returns false.

4. Compute it once, at module scope

// features.js — evaluated once per realm
export const features = {
  simd: WebAssembly.validate(SIMD_PROBE),
  threads: typeof SharedArrayBuffer === "function" && self.crossOriginIsolated === true,
};

Module-scope evaluation means the check runs exactly once regardless of how many places consult it, and the result is available synchronously wherever it is imported.

5. Use it to choose the artifact

import { features } from "./features.js";

const url = features.simd ? "/wasm/kernel.simd.wasm" : "/wasm/kernel.wasm";
const { instance } = await WebAssembly.instantiateStreaming(fetch(url), imports);

6. Repeat the check inside workers

Each worker is a separate realm and imports its own copy of the module, so the check runs again there. That is fine — it costs microseconds — but do not try to pass the boolean in the start message and skip it, because a worker with a different origin isolation state can legitimately differ on threads.

Expected output

Logged from a current browser and from an engine without the proposal:

> features
{ simd: true, threads: true }

> performance.now(); WebAssembly.validate(SIMD_PROBE); performance.now()
0.09    // milliseconds — the entire check

// an engine without SIMD
> features
{ simd: false, threads: false }

Sub-millisecond and side-effect free is the point. Because the cost is negligible, there is no reason to cache the result across sessions or to try to infer it from the user agent — both of which introduce staleness for no benefit.

Three ways to answer the question, only one of which is reliable Validating a probe asks the engine directly and costs microseconds. Parsing the user agent guesses from a version string and goes stale. Catching an instantiation failure works but only after the full binary has been downloaded. validate a probe asks the engine itself ≈ 0.1 ms, no network correct by construction parse the user agent guesses from a version string wrong for embedded and new engines goes stale silently try / catch the instantiate accurate, but late the whole binary already downloaded a wasted request per fallback user Keep the third as a safety net if you like — but decide with the first, before anything large is fetched.

Gotchas

The probe returns false on an engine you know supports SIMD. The bytes are wrong — usually a mis-transcribed length byte. Regenerate them with wat2wasm --enable-simd rather than editing by hand.

validate throws instead of returning false. It does not throw for unsupported features; a thrown error means the argument was not a buffer at all. Check what you passed.

Detection succeeds and instantiation still fails. The probe tested one instruction and the module uses another proposal too — threads, bulk memory, exception handling. Probe for each feature the build actually requires, or keep the try/catch as a net.

The check runs after the fetch has started. Then it has bought you nothing. Evaluate features before deciding a URL, not after.

Workers disagree with the page about threads. That is legitimate: crossOriginIsolated is a property of the realm. Recompute in each realm rather than sharing a boolean.

Evaluate once, import everywhere Putting the probes in a module whose top level runs the checks means they execute once per realm. Every other part of the application imports the resulting object and reads a boolean synchronously. features.js probes run once at module scope loader picks a binary UI shows a capability note telemetry records the split The telemetry consumer is worth adding: knowing what fraction of sessions lack a feature tells you when a fallback can be retired.

Performance note

The whole check is a validation of roughly thirty bytes, which is measured in tens of microseconds. There is no allocation worth mentioning, no parsing of a large module, and no network activity. Doing it once at module scope makes it effectively free; doing it per call would still be cheap, but there is no reason to.

The saving it produces is larger than its cost by orders of magnitude. Choosing the right binary up-front avoids downloading a module that will be rejected, which on a slow connection is the difference between a fast start and a wasted round trip followed by a second download.

Probing for more than one feature

Once you have one probe, adding others is mechanical, and a small features module is worth building even if you currently branch on only one. Each probe is a module using a single instruction from the proposal in question:

const probe = (bytes) => WebAssembly.validate(new Uint8Array(bytes));

export const features = {
  // v128.const — the SIMD proposal
  simd: probe([0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00, 0x01,0x05,0x01,0x60,0x00,0x01,0x7b,
               0x03,0x02,0x01,0x00, 0x0a,0x0a,0x01,0x08,0x00,0xfd,0x0c,0x00,0x00,0x00,0x00,0x0b]),

  // shared memory + atomics also need cross-origin isolation to be usable
  threads: typeof SharedArrayBuffer === "function" && self.crossOriginIsolated === true,

  // exception handling, tail calls and other proposals follow the same shape
};

Note the asymmetry in that object: SIMD is a pure engine capability, so a validation probe answers it completely. Threads are an engine capability and a document capability, because shared memory requires the page to be cross-origin isolated. Checking only typeof SharedArrayBuffer gives the wrong answer on a page that is not isolated, where the constructor may exist but the memory cannot be shared.

That distinction generalises: some features are decided entirely by the engine and some by the environment the page was served into. A features module that mixes both kinds is fine as long as each check is written to answer the question that actually matters — “can I use this here”, not “does this symbol exist”.

Reporting what you find

The probe results are also a cheap and useful telemetry signal. Recording which capabilities a session had, alongside whichever build was loaded, answers two questions you will eventually want answered: what fraction of your users would lose functionality if you dropped a fallback, and whether the fallback path is being exercised at all.

metrics.event("wasm.features", { simd: features.simd, threads: features.threads });

A single event per session is negligible volume and turns a decision that is usually made by guesswork — “can we drop the baseline build yet?” — into one made from data. In most current audiences the SIMD figure is high enough to be surprising, and knowing that is what justifies eventually simplifying the deployment.

Frequently Asked Questions

Should I cache the result in localStorage? No. It costs less to recompute than to read from storage, and a cached answer can be wrong after a browser update. Feature detection that is this cheap should always be live.

Can I detect relaxed SIMD the same way? Yes — build a probe containing a relaxed instruction and validate it. The pattern generalises to every proposal, which is why it is worth having a small features module rather than one ad-hoc check.

What if I only ship a SIMD build? Then detection is not a branch but a diagnostic: if the probe fails, show a clear message rather than letting the user see an unexplained failure. See shipping SIMD and baseline builds together for the two-artifact alternative.

Where should the probe run in a framework application? As early as possible, and outside any component lifecycle — a module-scope evaluation in a file imported by the entry point. Running it inside a component means it happens after the framework has booted, by which point you may already have needed the answer to decide what to fetch.

Is there a standard set of probe modules? Several libraries ship them, and they are worth using if you are already pulling in a dependency for other reasons. For one or two features, inlining the bytes avoids a dependency for something that is thirty bytes and never changes.

Does the probe need to be the same instruction my module uses? Not exactly the same, but it must come from the same proposal. Any SIMD instruction answers the SIMD question, because engines implement the proposal as a unit rather than instruction by instruction. What it will not answer is whether a different proposal is supported, which is why a module using both SIMD and threads needs both probes.

← Back to Wasm SIMD & Vectorized Computation