Using SIMD in Worker Threads

WebAssembly SIMD gives you a 128-bit v128 register file β€” four f32 lanes, eight i16 lanes or sixteen i8 lanes processed by one instruction β€” and a dedicated worker gives you a thread on which to spend it without touching frame rendering. This page covers the narrow problem of wiring the two together: detecting v128 support from inside worker scope, loading the right binary, and knowing when the speedup survives contact with the data-copying costs around it.

It is a deep dive under WebAssembly in Workers, the parent guide for this topic, which in turn sits inside the High-Performance Computation Patterns reference. The instantiation mechanics β€” streaming compilation, module caching, import objects β€” are covered once in Instantiating WebAssembly Modules Inside Workers and are not repeated here; nothing about them changes when the binary happens to contain vector instructions.

Minimal Reproducible Example

The whole pattern is four steps that happen entirely inside the worker: probe β†’ choose a URL β†’ instantiate β†’ dispatch. The main thread never learns which binary was chosen except as a diagnostic flag.

Probe, choose, instantiate, dispatch β€” all four steps live in worker scope Two swim lanes. The left lane is the main thread; the right lane is the dedicated worker. Step one, at worker start in module scope, the worker evaluates hasSIMD by calling WebAssembly.validate on a twenty-nine byte probe; the call is synchronous, compiles nothing, costs microseconds, and its answer cannot change while the worker is alive. Step two, when the main thread posts INIT, the worker branches on that boolean: true selects /wasm/image-filter-simd.wasm, built with -msimd128; false selects /wasm/image-filter-scalar.wasm, a plain -O3 release build. Only the selected URL is fetched. Step three, both branches converge on a single await of WebAssembly.instantiateStreaming with the chosen URL and the import object, and the exports stay in worker scope as filterFn and wasmMem; a READY message carrying the simd flag goes back to the main thread as a diagnostic. Step four, each RUN message transfers a pixel ArrayBuffer in, and the worker copies it into linear memory with heap.set β€” roughly six milliseconds for a thirty-three megabyte 4K frame β€” then calls apply_filter, which moves sixteen bytes per v128 step and is bracketed by performance.now, then copies the output into a fresh ArrayBuffer and posts it back as a transfer. The same WebAssembly.Memory of sixty-four pages, four megabytes, is reused for every message. main thread dedicated worker scope 1 Β· at worker start (module scope) const hasSIMD = detectSIMD() WebAssembly.validate(29 probe bytes) synchronous Β· no compile Β· runs once costs microseconds; the answer cannot change while this worker is alive 2 Β· on INIT β€” fetch one hasSIMD === true /wasm/image-filter-simd.wasm built with -msimd128 hasSIMD === false /wasm/image-filter-scalar.wasm plain -O3 release build 3 Β· instantiate once await WebAssembly.instantiateStreaming(fetch(wasmUrl), imports) exports stay in worker scope β€” filterFn = apply_filter, wasmMem = memory 4 Β· on RUN β€” one message per frame copy in heap.set(pixels, 0) 33 MB frame β‰ˆ 6 ms apply_filter(0, len) 16 bytes per v128 step bracketed by performance.now() copy out new ArrayBuffer(len) posted as a transfer WebAssembly.Memory({ initial: 64 }) β€” 4 MB of linear memory, reused for every message postMessage { type: 'INIT' } onmessage READY { simd } postMessage RUN + [pixels] onmessage RESULT + kernelMs transfer result buffer transferred back β€” no structured clone
Every decision that depends on v128 support is made inside worker scope; the main thread only ever learns which build ran as a flag on the reply.
// simd-worker.ts
// Two builds of the same source are served at known paths:
//   /wasm/image-filter-simd.wasm    (compiled with -msimd128)
//   /wasm/image-filter-scalar.wasm  (plain release build)

interface FilterExports extends WebAssembly.Exports {
  memory: WebAssembly.Memory;
  apply_filter: (ptr: number, len: number) => void;
}

let filterFn: ((ptr: number, len: number) => void) | null = null;
let wasmMem: WebAssembly.Memory | null = null;

/**
 * Validates a 29-byte module whose only function body is
 * `i32.const 0; i8x16.splat; drop` β€” the smallest legal use of a
 * v128 instruction. Returns true when the engine accepts SIMD opcodes.
 */
function detectSIMD(): boolean {
  const probe = new Uint8Array([
    0x00, 0x61, 0x73, 0x6d,             // magic: \0asm
    0x01, 0x00, 0x00, 0x00,             // version 1
    0x01, 0x04, 0x01, 0x60, 0x00, 0x00, // type section: 1 type, () -> ()
    0x03, 0x02, 0x01, 0x00,             // function section: 1 func, type 0
    0x0a, 0x09, 0x01,                   // code section: 1 body
    0x07, 0x00,                         // body size 7, 0 local groups
    0x41, 0x00,                         // i32.const 0
    0xfd, 0x0f,                         // i8x16.splat  (0xfd = SIMD prefix)
    0x1a,                               // drop
    0x0b,                               // end
  ]);
  return WebAssembly.validate(probe);
}

// The probe result cannot change during the worker's lifetime.
const hasSIMD = detectSIMD();

self.onmessage = async ({ data }) => {
  if (data.type === 'INIT') {
    const wasmUrl = hasSIMD
      ? '/wasm/image-filter-simd.wasm'
      : '/wasm/image-filter-scalar.wasm';

    const imports: WebAssembly.Imports = {
      env: { memory: new WebAssembly.Memory({ initial: 64 }) }, // 64 pages = 4MB
    };

    // Streaming compilation: bytes are compiled as they arrive.
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch(wasmUrl), imports
    );
    const e = instance.exports as FilterExports;
    filterFn = e.apply_filter;
    wasmMem = e.memory;

    self.postMessage({ type: 'READY', simd: hasSIMD });
  }

  if (data.type === 'RUN' && filterFn && wasmMem) {
    const pixels: ArrayBuffer = data.pixels; // transferred, not cloned
    const heap = new Uint8Array(wasmMem.buffer);
    heap.set(new Uint8Array(pixels), 0);

    const t0 = performance.now();
    filterFn(0, pixels.byteLength);
    const kernelMs = performance.now() - t0;

    // Copy the result out of linear memory and transfer it back.
    const result = new ArrayBuffer(pixels.byteLength);
    new Uint8Array(result).set(new Uint8Array(wasmMem.buffer, 0, pixels.byteLength));
    self.postMessage(
      { type: 'RESULT', id: data.id, result, kernelMs, simd: hasSIMD },
      [result]
    );
  }
};

The main-thread half is deliberately dull β€” it does not care which build ran:

// main.ts
const worker = new Worker(new URL('./simd-worker.ts', import.meta.url), { type: 'module' });
worker.postMessage({ type: 'INIT' });

worker.onmessage = ({ data }) => {
  if (data.type === 'READY') {
    console.info(`filter worker ready β€” vectorised build: ${data.simd}`);
  }
  if (data.type === 'RESULT') {
    console.info(`kernel: ${data.kernelMs.toFixed(2)}ms (simd=${data.simd})`);
    renderFrame(data.result);
  }
};

function applyFilter(pixelBuffer: ArrayBuffer): void {
  // The buffer is neutered here; the caller must not touch it afterwards.
  worker.postMessage(
    { type: 'RUN', id: crypto.randomUUID(), pixels: pixelBuffer },
    [pixelBuffer]
  );
}

Line-by-Line Walkthrough

The 29-byte probe, byte by byte A strip of twenty-nine bytes divided into six labelled spans. Bytes 00 61 73 6d are the magic number; 01 00 00 00 is version 1; 01 04 01 60 00 00 is the type section declaring one function type taking nothing and returning nothing; 03 02 01 00 is the function section; 0a 09 01 07 00 is the code section header, giving one body of seven bytes with no local groups. The final six bytes are the function body, magnified below. 0x41 0x00 is i32.const 0, which pushes a zero onto the stack. 0xfd 0x0f is i8x16.splat, where the 0xfd prefix opens the SIMD opcode space β€” this is the byte the whole probe exists to test. 0x1a is drop, which discards the resulting v128. 0x0b is end, which closes the function body. WebAssembly.validate returning true means the engine accepts the 0xfd prefix; a malformed hand-rolled probe fails validation for the wrong reason and silently pins every visitor to the scalar build. the entire module 29 bytes magic version 1 type section () β†’ () function section code section header function body 0061736d 01000000 010401600000 03020100 0a09010700 4100fd0f1a0b 0x41 0x00 i32.const 0 pushes a zero onto the stack 0xfd 0x0f i8x16.splat 0xfd opens the SIMD opcode space 0x1a drop discards the resulting v128 0x0b end closes the function body WebAssembly.validate(bytes) β†’ true means the engine accepts the 0xfd prefix. A malformed hand-rolled probe fails validation for the wrong reason and silently pins every visitor to the scalar build.
Only two of the twenty-nine bytes carry the question. Everything else exists so that those two sit somewhere the validator will look at them.

The probe body, byte by byte. Everything before offset 0x18 is boilerplate that declares one function of type () -> (). The interesting part is the body: 0x41 0x00 pushes i32.const 0, 0xfd 0x0f is i8x16.splat (the 0xfd prefix opens the SIMD opcode space), 0x1a drops the resulting v128, and 0x0b ends the function. An engine without SIMD rejects the 0xfd prefix during validation. This is the exact module wasm-feature-detect ships; hand-rolling your own probe is easy to get subtly wrong, and a malformed probe fails validation for the wrong reason and silently pins every visitor to the scalar path.

WebAssembly.validate is synchronous and does not instantiate. It parses and type-checks the bytes, then throws the result away β€” no compilation, no memory allocation, no promise. That is why the call sits at module scope in the worker rather than inside the message handler: it costs microseconds, cannot change while the worker lives, and running it once removes it from the per-message path.

Why the probe belongs in the worker. A dedicated worker shares the engine and its feature flags with the page that spawned it, so the answer is the same in both contexts β€” but the decision that depends on it is the worker’s. Keeping the probe and the URL choice inside worker scope means the main-thread bundle never references either binary, so no bundler is tempted to preload both.

Two builds, one fetch. The SIMD and scalar binaries are separate artifacts and only the selected one is ever downloaded. This is the compatibility pattern that lets you use vector instructions unconditionally in the fast build: there is no runtime dispatch inside the WebAssembly module itself, no dead-code branches, and no need to keep the two implementations behaviourally identical beyond their observable output.

instantiateStreaming(fetch(url), imports). Identical to the non-SIMD case. The one thing SIMD adds is that the server must send Content-Type: application/wasm, or streaming compilation refuses the response and you fall back to the slower arrayBuffer() path β€” an easy thing to miss when the vectorised binary is added later to a directory that was already serving other assets.

performance.now() around the kernel only. The bracket deliberately excludes the heap.set copy in and the copy out. That isolates the number you are trying to improve β€” the arithmetic β€” from the transfer costs, which the vectoriser cannot touch. Report both separately; conflating them is the single most common reason a β€œ4Γ— faster” kernel produces a 1.3Γ— faster feature.

Building the Two Binaries

One source, two compiles, two sets of opcodes A single source file, filter.c, guarded by an ifdef on __wasm_simd128__, feeds two independent toolchain invocations. The upper path runs emcc filter.c -O3 -msimd128 --no-entry -o image-filter-simd.wasm; the -msimd128 flag defines __wasm_simd128__, so the intrinsics branch compiles, and the hot loop of the resulting binary contains v128.load, i8x16.sub_sat and v128.store β€” one instruction moves sixteen bytes. The lower path runs the same command without -msimd128, producing image-filter-scalar.wasm; the macro is undefined so the else branch compiles, and the hot loop contains i32.load8_u, i32.sub and i32.store8 β€” one byte per iteration. Both artifacts are deployed and the worker downloads exactly one of them, and both must be served with the application/wasm content type or instantiateStreaming will reject the response. filter.c one source file, compiled twice #ifdef __wasm_simd128__ vectorised build emcc filter.c -O3 -msimd128 --no-entry -o …-simd.wasm -msimd128 defines the macro, so the intrinsics branch compiles fallback build emcc filter.c -O3 --no-entry -o …-scalar.wasm same source, macro undefined, so the #else loop compiles image-filter-simd.wasm opcodes in the hot loop v128.load i8x16.sub_sat v128.store one instruction moves 16 bytes image-filter-scalar.wasm opcodes in the hot loop i32.load8_u i32.sub i32.store8 one instruction moves 1 byte Both artifacts are deployed; the worker downloads exactly one. Serve both with Content-Type: application/wasm β€” instantiateStreaming refuses anything else and falls back to the slower arrayBuffer() path.
The two builds differ by one flag and by nothing else in the source; the difference shows up only in the opcodes, which is why disassembling the output is the only way to confirm the vectoriser did its job.

With Emscripten, the vectorised build differs from the scalar one by a single flag, and the intrinsics header lets you write vector code explicitly instead of hoping the auto-vectoriser recognises your loop:

// filter.c β€” compiled twice; the SIMD path is guarded at compile time
#include <stdint.h>
#include <emscripten.h>
#ifdef __wasm_simd128__
#include <wasm_simd128.h>
#endif

EMSCRIPTEN_KEEPALIVE
void apply_filter(uint8_t *px, int len) {
#ifdef __wasm_simd128__
  const v128_t bias = wasm_u8x16_splat(16);
  int i = 0;
  for (; i + 16 <= len; i += 16) {          // 16 bytes per iteration
    v128_t v = wasm_v128_load(px + i);
    v = wasm_u8x16_sub_sat(v, bias);        // saturating: no wraparound
    wasm_v128_store(px + i, v);
  }
  for (; i < len; i++) {                    // scalar tail: len % 16 bytes
    px[i] = px[i] > 16 ? px[i] - 16 : 0;
  }
#else
  for (int i = 0; i < len; i++) {
    px[i] = px[i] > 16 ? px[i] - 16 : 0;
  }
#endif
}
# vectorised build β€” __wasm_simd128__ is defined by -msimd128
emcc filter.c -O3 -msimd128 --no-entry -o image-filter-simd.wasm
# scalar build β€” same source, no vector opcodes emitted
emcc filter.c -O3 --no-entry -o image-filter-scalar.wasm

The Rust equivalent is RUSTFLAGS="-C target-feature=+simd128" cargo build --target wasm32-unknown-unknown --release for the fast artifact and a plain release build for the fallback, with intrinsics from core::arch::wasm32 (v128, u8x16_sub_sat, v128_load) behind a #[cfg(target_feature = "simd128")] gate.

Two details from the C above generalise to every kernel. The scalar tail is mandatory: v128 loads move exactly 16 bytes, so anything not a multiple of the lane width has to be finished element by element β€” an RGBA row of 1023 pixels leaves 12 bytes for the tail loop. And saturating arithmetic (sub_sat, add_sat) is usually what pixel and audio code wants; the wrapping variants turn an over-darkened pixel into a bright one, which reads as sparkle noise in the output rather than as an obvious bug.

When Vectorisation Actually Pays

Which workload shapes the vector units can actually help Two columns. The left column, vectorises well β€” contiguous, same-typed, branch-free. Elementwise array kernels: uint8 RGBA channel maths and float32 gain and mixing, sixteen bytes or four floats per instruction with no cross-iteration dependencies. Reductions: sums, dot products and min/max scans, accumulated into four partial lanes then folded once at the end. Blocked linear algebra: matrix multiply and convolution over aligned tiles, where the same weights multiply many contiguous inputs. Byte-level codecs: base64, UTF-8 validation and run-length encoding, using fixed-width shuffles and table lookups across a contiguous byte stream. The right column, stays flat β€” no compiler flag changes the outcome. Pointer chasing: linked lists, trees and open-addressed hash tables, where cache misses set the pace and there is nothing to vectorise. Per-lane divergence: neighbouring elements take different branches, so the vector form computes both sides and blends, often slower than the scalar original. Inputs under a few kilobytes: call overhead, the copy into linear memory and the loop prologue swamp the arithmetic saving entirely. Latency-bound work: waiting on the network, on IndexedDB, or on a decode already running on another thread, while the vector units sit idle. Vectorises well contiguous Β· same-typed Β· branch-free Stays flat no compiler flag changes the outcome Elementwise array kernels uint8 RGBA channel maths, float32 gain and mixing β€” 16 bytes or 4 floats per instruction, no cross-iteration dependencies Reductions sums, dot products, min/max scans β€” accumulate into four partial lanes, then fold once at the very end Blocked linear algebra matrix multiply and convolution over aligned tiles, where the same weights multiply many contiguous inputs Byte-level codecs base64, UTF-8 validation, RLE β€” fixed-width shuffles and table lookups across a contiguous byte stream Pointer chasing linked lists, trees, open-addressed hash tables β€” cache misses set the pace and there is nothing to vectorise Per-lane divergence neighbouring elements take different branches, so the vector form computes both sides and blends β€” often slower Inputs under a few kilobytes call overhead, the copy into linear memory and the loop prologue swamp the arithmetic saving entirely Latency-bound work waiting on the network, on IndexedDB, or on a decode already running on another thread β€” the vector units sit idle
The left column is not a list of domains but of loop shapes; a workload from the left column written with per-element branching lands in the right one.

The compiler β€” LLVM’s auto-vectoriser via Emscripten or wasm-pack, or Binaryen afterwards β€” emits vector opcodes when it can prove an inner loop is uniform over contiguous, same-typed elements. In practice that means:

  • Elementwise array kernels. uint8 RGBA channel maths, float32 audio gain and mixing, fixed-point filters. Sixteen bytes or four floats per instruction, no data dependencies between iterations.
  • Reductions. Sums, dot products, min/max scans β€” accumulate into four partial lanes, then fold once at the end.
  • Blocked linear algebra. Matrix multiplication and convolution over aligned tiles, where the same weights multiply many contiguous inputs.

The workloads that stay flat, no matter which flags you pass:

  • Pointer chasing. Linked lists, trees, hash tables with open addressing β€” cache misses set the pace and there is nothing to vectorise between them.
  • Per-lane divergence. If neighbouring elements take different branches, the vector form has to compute both sides and blend; that is often slower than the scalar original.
  • Small inputs. Below a few kilobytes, the call overhead, the copy into linear memory and the loop prologue swamp the arithmetic saving entirely.
  • Latency-bound work. Anything waiting on the network, on IndexedDB, or on a decode already happening on another thread.

Measured Speedups

Kernel time, scalar versus vectorised, for six real workloads A grouped horizontal bar chart in milliseconds of kernel time only, with copies excluded, measured on an M2 MacBook Air and a Snapdragon 8 Gen 2, both Chrome 124. A 4K RGBA Gaussian blur with a 3 by 3 kernel falls from 52 to 13 milliseconds, a 4.0 times speedup. A 1 million element float32 dot product falls from 27 to 7 milliseconds, 3.9 times. Ten seconds of 44.1 kilohertz stereo audio RMS-normalised falls from 19 to 5 milliseconds, 3.8 times. A base64 decode of a 2 megabyte input falls from 11 to 4 milliseconds, 2.8 times. The last two rows are shaded because nothing changes: a textbook SHA-256 over 1 megabyte goes from 48 to 50 milliseconds, and a binary struct parse that branches per record goes from 18 to 19 milliseconds β€” both roughly 1.0 times. SHA-256 has a serial dependency chain through its compression rounds, and the struct parser diverges per record. scalar build vectorised build (-msimd128) kernel time only β€” copies excluded 01020 304050 ms 4K RGBA Gaussian blur, 3Γ—3 kernel 1M-element float32 dot product 10 s of 44.1 kHz stereo, RMS normalise Base64 decode, 2 MB input SHA-256 over 1 MB, textbook impl. Binary struct parse, branch per record 52 ms13 ms 27 ms7 ms 19 ms5 ms 11 ms4 ms 48 ms50 ms 18 ms19 ms 4.0Γ—3.9Γ— 3.8Γ—2.8Γ— 1.0Γ—~1.0Γ— The shaded pair is the point: a serial dependency chain (SHA-256) and per-record branching do not vectorise at any flag. M2 MacBook Air and Snapdragon 8 Gen 2, both Chrome 124, at production-representative input sizes.
Category alone predicts nothing β€” the two shaded rows sit in domains that sound vectorisable and are not. Measure your own kernel before committing to a second binary.

Kernel-only timings, taken inside the worker with the bracket shown above, on an M2 MacBook Air and a Snapdragon 8 Gen 2 phone (both Chrome 124), at production-representative input sizes:

Workload Scalar build Vectorised build Speedup
4K RGBA Gaussian blur, 3Γ—3 kernel 52ms 13ms 4.0Γ—
1M-element float32 dot product 27ms 7ms 3.9Γ—
10s of 44.1kHz stereo audio, RMS normalise 19ms 5ms 3.8Γ—
Base64 decode, 2MB input 11ms 4ms 2.8Γ—
SHA-256 over 1MB, reference implementation 48ms 50ms 1.0Γ—
Binary struct parse with per-record branching 18ms 19ms ~1.0Γ—

The bottom two rows are the useful ones. A textbook SHA-256 has a serial dependency chain through its compression rounds, so no flag makes it faster β€” vectorised hashing requires hashing several independent messages in parallel lanes, a different algorithm rather than a different build. The struct parser branches per record, which is exactly the divergence case above. Category alone predicts nothing; measure your own kernel before committing to a second binary.

Gotchas and Edge Cases

Diagnosing a vectorised build that is no faster One symptom β€” the vectorised build is no faster β€” splits into four observations, each with its own cause and fix. First, if the disassembly shows no v128 opcodes and wasm-objdump -d finds none, the auto-vectoriser declined: aliasing, a non-unit stride or an un-inlined call defeated it, so write the intrinsics by hand and fail the build if the opcodes are absent. Second, if detectSIMD returns false for every visitor everywhere, the engine has no v128 β€” Safari before 16.4, or a vendor WebView running a Chromium version with SIMD switched off β€” so keep the scalar binary a real, maintained build. Third, if the kernel is fast but the feature is not, meaning kernelMs fell while wall-clock time did not, the copies dominate rather than the arithmetic: 33 megabytes in and 33 megabytes out cost about six milliseconds each, so keep the buffer in linear memory across passes or share it between workers. Fourth, if baseline v128 works but relaxed_madd does not, relaxed SIMD is a separate feature: -mrelaxed-simd needs its own probe and its own artifact, and its results can differ in the last bit between engines, so never use it for a checksum. what you observe cause, and what to do about it symptom The vectorised build is no faster Disassembly shows no v128 opcodes wasm-objdump -d finds none The auto-vectoriser declined Aliasing, a non-unit stride or an un-inlined call β€” write the intrinsics by hand and fail the build if the opcodes are absent. detectSIMD() returns false for every visitor, everywhere The engine has no v128 Safari before 16.4, or a vendor WebView on a Chromium version with SIMD switched off. Keep the scalar binary maintained. Kernel is fast, the feature is not kernelMs fell, wall-clock did not The copies dominate, not the arithmetic 33 MB in and 33 MB out cost about 6 ms each. Keep the buffer in linear memory across passes, or share it between workers. Baseline v128 works, relaxed_madd does not fused multiply-add missing Relaxed SIMD is a separate feature -mrelaxed-simd needs its own probe and its own artifact, and its results can differ in the last bit β€” never use it for a checksum. Every branch is diagnosed from the binary or from the probe β€” never from a user-agent string.
Four symptoms that look identical from the outside. The disassembly separates the first from the second, and reporting kernelMs alongside wall-clock time separates the third from both.

Enabling the target feature does not guarantee vectorised output. -msimd128 permits the compiler to emit v128 instructions; it does not oblige it to. A loop with an aliasing ambiguity, a non-unit stride, or a call it cannot inline will compile to ordinary scalar opcodes and your β€œSIMD build” will be byte-for-byte as slow as the fallback. Confirm before believing: open the binary in the DevTools Sources panel β€” Chrome and Firefox both disassemble .wasm inline β€” and look for f32x4.*, i16x8.* or v128.load inside the hot function, or run wasm-objdump -d in the build pipeline and fail the build if the opcodes are absent. Where it matters, write the intrinsics by hand as in the C above rather than negotiating with the vectoriser.

Copying into linear memory can cost more than the kernel saves. A 4K RGBA frame is 33MB; heap.set() in and the copy out cost several milliseconds each, which is the same order as the entire vectorised blur. If the buffer is processed repeatedly β€” successive filter passes, a video pipeline β€” write it into WebAssembly memory once and keep it there, addressing it with pointers across calls. When several workers need to see the same pixels, the shared-memory approach in Sharing WebAssembly Linear Memory Across Workers removes the copy altogether; when data flows one way through a pipeline, hand ownership along with Transferable Objects & Zero-Copy instead.

Combining SIMD with WebAssembly threads needs cross-origin isolation

SIMD itself has no header requirements β€” the example on this page works on any origin. But the moment you pair it with a threaded build (Emscripten -pthread, or WebAssembly.Memory({ shared: true })), the memory is backed by a SharedArrayBuffer and the document must be served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Gate that path on crossOriginIsolated so a missing header degrades to single-threaded vector code instead of throwing at startup.

Relaxed SIMD is a different feature with a different probe. The relaxed-SIMD proposal (-mrelaxed-simd, opcodes such as f32x4.relaxed_madd) shipped later than baseline SIMD and permits engine-specific results β€” fused versus unfused multiply-add can differ in the last bit between browsers. Detecting v128 tells you nothing about it: use a separate probe, keep the relaxed build as a third artifact if you want it, and never use it for anything whose output must be bit-identical across clients, such as a checksum or a reproducible render.

Safari and older WebViews land on the fallback. Baseline SIMD shipped in Chrome 91 and Firefox 89 (2021) but not until Safari 16.4 / iOS 16.4 (March 2023), and some vendor Android WebViews still run with the feature disabled while reporting a Chromium version that has it. This is precisely why the decision is made by validate rather than by a user-agent string, and why the scalar binary has to remain a real, maintained build rather than a stub β€” on a non-trivial share of mobile traffic it is the one that runs.

Performance Note

Where the milliseconds go for one 4K frame Two stacked timeline bars on a millisecond scale. The scalar build spends 6 milliseconds copying the frame into linear memory, 52 milliseconds in the kernel, and 6 milliseconds copying the result out β€” 64 milliseconds end to end. The vectorised build spends the same 6 milliseconds copying in, 13 milliseconds in the kernel, and the same 6 milliseconds copying out β€” 25 milliseconds end to end. The arithmetic is 4.0 times faster but the whole feature is only 1.9 times faster, because roughly 12 milliseconds of copying is identical in both bars and untouched by any instruction set. Vectorise only after the data path is already zero-copy, and report kernel time and wall-clock time as separate fields so the gap between them says whether a slow CPU or a slow copy caused a regression. copy into / out of linear memory scalar kernel vectorised kernel scalar build one 4K RGBA frame 6 ms kernel Β· 52 ms 6 ms 64 ms end to end vectorised build -msimd128, same source 6 ms kernel Β· 13 ms 6 ms 25 ms end to end 020 ms40 ms60 ms 4.0Γ— on the kernel, 1.9Γ— end to end 52 β†’ 13 ms of arithmetic, but 64 β†’ 25 ms measured from postMessage to rendered frame β‰ˆ12 ms of copying is untouched by any instruction set β€” vectorise only after the data path is already zero-copy. Report kernelMs and wall-clock as separate fields: the gap between them says whether a slow CPU or a slow copy caused the regression.
The brown blocks are identical in both bars. That invariance is the whole reason a 4Γ— kernel produces a 1.9Γ— feature β€” and the reason the copy path, not the instruction set, is the first thing to fix.

The rule of thumb worth memorising: on this hardware, a well-vectorised elementwise kernel runs 3–4Γ— faster than its scalar twin, but the end-to-end feature speeds up by roughly half that once the copies into and out of linear memory are counted. The 4K blur above is the concrete case β€” 52ms β†’ 13ms of arithmetic, but 64ms β†’ 25ms measured from postMessage to rendered frame, because ~12ms of copying is unaffected by any instruction set. That is still the difference between four dropped frames and one, and the worker means neither number is spent on the main thread; it is just not the 4Γ— the microbenchmark advertises.

Two consequences follow. Vectorise only after the data path is already zero-copy, or you will be optimising the smaller half of the budget β€” the same ordering argument the Image Processing in Workers guide makes about ImageData handling. And always report the kernel time and the wall-clock time as separate fields in the result message, as kernelMs does above: when a device regresses, the gap between the two tells you immediately whether you are looking at a slow CPU or a slow copy.

Frequently Asked Questions

How do I detect whether the browser supports WebAssembly SIMD at runtime?
Attempt to validate a tiny WebAssembly binary containing a v128 instruction: if WebAssembly.validate(bytes) returns true, the engine accepts the SIMD opcode set. This is what wasm-feature-detect does behind its one-liner (import { simd } from 'wasm-feature-detect'; const hasSIMD = await simd();). Never branch on the browser version string β€” some Android WebViews and embedded runtimes report a version whose desktop counterpart has SIMD while running with the feature disabled, and validate is the only answer that reflects the engine you are actually executing on.
Does SIMD help every workload, or only specific ones?
Only workloads whose inner loop is data-parallel over contiguous, same-typed elements: RGBA pixel kernels, float32 audio frames, dot products and matrix blocks, FFT butterflies, byte-level codecs. It does nothing for pointer-chasing structures, per-element branchy logic, or anything dominated by memory latency rather than arithmetic. The practical filter is arithmetic intensity: if the kernel performs at least a few operations per byte loaded and the input is tens of kilobytes or larger, vectorising is worth trying; below that, load and store bandwidth sets the floor and the vector units idle.

See also