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.
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 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
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
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.
uint8RGBA channel maths,float32audio 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-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
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.
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
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.