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.
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
- [ ]
wat2wasmif 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.
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.
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.
Related
- Wasm SIMD & vectorized computation — what the feature buys once detected.
- Shipping SIMD and baseline builds together — the deployment this check feeds.
- Polyfills, alternatives & fallbacks — the general detection ladder.
- Writing your first WAT module by hand — where the probe bytes come from.
← Back to Wasm SIMD & Vectorized Computation