WebAssembly in Workers
Running WebAssembly inside Web Workers combines two browser primitives that each add value independently, and dramatically more value together. This page is part of the High-Performance Computation Patterns reference and covers every production concern: compile-once/instantiate-many, the linear memory model, growing memory, shared memory across workers, imports and exports typing, data-transfer strategy, measurement, and failure modes.
Why WebAssembly Belongs in a Worker
JavaScriptβs event loop runs on the main thread. A 150ms WASM image-filter computation that lives on the main thread drops roughly nine 16ms frames β the user sees the cursor freeze. The same computation on a dedicated worker is invisible to the UI because the worker has its own event loop and its own V8 isolate.
WebAssembly adds a second dimension: raw throughput. WASM executes at machine-code speed, bypasses the JS JIT warm-up period, and has deterministic instruction counts absent of GC pauses. Workloads that see the biggest gain:
- Codec work: Ogg/Vorbis decode, H.264 demuxing, FLAC processing β 3β10Γ faster than equivalent JS.
- Numerical algorithms: matrix factorisation, FFTs, physics integration β code that translates directly to tight inner loops.
- Image and signal processing: per-pixel transforms, convolution kernels, histogram operations β benefits further from WASM SIMD.
- Cryptographic primitives: argon2, blake3, ChaCha20 β workloads where timing predictability matters.
Combining WASM with workers means: (1) no frame drops, (2) peak throughput, and (3) potential parallelism across multiple worker threads accessing shared linear memory.
You should be comfortable creating dedicated workers and using postMessage. Familiarity with Transferable Objects & Zero-Copy is helpful because WASM results are typically returned as ArrayBuffer transfers.
Step 1 β Compile the Module Once
WebAssembly compilation β parsing the binary and translating it to native machine code β is the expensive step. For a 500KB .wasm binary, WebAssembly.compileStreaming takes 10β50ms depending on hardware. Repeating that cost in every worker defeats the purpose.
The fix is a compile-once pattern: compile on the main thread (or a dedicated compiler worker), then transfer the WebAssembly.Module struct to each worker via postMessage. A WebAssembly.Module is fully structured-cloneable per the spec, so postMessage copies the already-compiled artefact β no re-download, no re-parse.
// main.ts
const modulePromise: Promise<WebAssembly.Module> = WebAssembly.compileStreaming(
fetch('/wasm/compute.wasm')
);
const worker = new Worker(new URL('./wasm-worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = ({ data }) => {
if (data.type === 'READY') {
console.log('Worker instantiated in', data.instantiateMs.toFixed(2), 'ms');
}
if (data.type === 'RESULT') {
// data.buffer is the transferred result ArrayBuffer
renderResult(data.buffer);
}
};
worker.onerror = (e) => console.error('Worker error:', e.message);
(async () => {
const compiledModule = await modulePromise;
// Transfer the compiled module β structured clone, not transferable
worker.postMessage({ type: 'INIT', module: compiledModule });
})();
Compilation (binary β machine code) is O(module size) and takes 10β50ms for a 500KB binary. Instantiation (allocating memory, resolving imports) is sub-millisecond for most modules. Compile once, instantiate in every worker that needs the module.
Step 2 β Instantiate Inside the Worker
Once the worker receives the compiled WebAssembly.Module, it calls WebAssembly.instantiate(module, imports). The imports object satisfies the moduleβs declared host imports β functions, memories, tables, and globals that the WASM code calls into the host environment.
// wasm-worker.ts
let wasmExports: WebAssembly.Exports | null = null;
interface InitMessage { type: 'INIT'; module: WebAssembly.Module }
interface RunMessage { type: 'RUN'; inputBuffer: ArrayBuffer; width: number; height: number }
type WorkerMessage = InitMessage | RunMessage;
self.onmessage = async ({ data }: MessageEvent<WorkerMessage>) => {
if (data.type === 'INIT') {
const t0 = performance.now();
const imports: WebAssembly.Imports = {
env: {
// Provide console.log equivalent to WASM code
log_i32: (value: number) => console.log('[wasm]', value),
memory: new WebAssembly.Memory({ initial: 16 }), // 16 Γ 64KB = 1MB
},
};
const { instance } = await WebAssembly.instantiate(data.module, imports);
wasmExports = instance.exports;
const instantiateMs = performance.now() - t0;
self.postMessage({ type: 'READY', instantiateMs });
}
if (data.type === 'RUN' && wasmExports) {
runCompute(data.inputBuffer, data.width, data.height);
}
};
function runCompute(inputBuffer: ArrayBuffer, width: number, height: number): void {
const processFrame = wasmExports!.process_frame as (
ptr: number, width: number, height: number
) => number;
const mem = (wasmExports!.memory as WebAssembly.Memory).buffer;
const heap = new Uint8Array(mem);
// Copy input into WASM linear memory at offset 0
heap.set(new Uint8Array(inputBuffer), 0);
const resultPtr = processFrame(0, width, height);
const byteLen = width * height * 4; // RGBA
// Copy out the result into a fresh ArrayBuffer
const resultBuffer = new ArrayBuffer(byteLen);
new Uint8Array(resultBuffer).set(heap.subarray(resultPtr, resultPtr + byteLen));
// Transfer zero-copy back to main thread
self.postMessage({ type: 'RESULT', buffer: resultBuffer }, [resultBuffer]);
}
If you call WebAssembly.instantiateStreaming directly in the worker instead of receiving a pre-compiled module, the server must send Content-Type: application/wasm. A text/plain or missing MIME type causes an immediate rejection in Chrome, Firefox, and Safari. The fallback is fetch().then(r => r.arrayBuffer()).then(buf => WebAssembly.instantiate(buf, imports)), which works with any MIME type but loses streaming compilation.
Step 3 β The Linear Memory Model
WebAssembly has a flat, contiguous byte array called linear memory β a WebAssembly.Memory object backed by an ArrayBuffer. The WASM module addresses this memory with 32-bit integer offsets (pointers). Your JavaScript glue code must:
- Decide where in linear memory each input payload lives (e.g. at offset 0 for a single-threaded worker).
- Copy or write the input to that address.
- Call the WASM export that processes it.
- Read results back from the output address returned by (or agreed upon with) the WASM code.
Linear memory starts at initial * 64KB bytes and can grow with Memory.grow(delta) β each delta unit is one page (64KB). Growing memory returns the previous page count on success or -1 on failure. A maximum page count prevents unbounded growth.
// Demonstrating memory growth
function growIfNeeded(mem: WebAssembly.Memory, requiredBytes: number): void {
const currentBytes = mem.buffer.byteLength;
if (currentBytes >= requiredBytes) return;
const extraPages = Math.ceil((requiredBytes - currentBytes) / 65536);
const prev = mem.grow(extraPages);
if (prev === -1) {
throw new Error(`WebAssembly.Memory.grow failed: requested ${extraPages} pages`);
}
// After grow(), mem.buffer is a NEW ArrayBuffer β existing typed-array views are detached!
}
// Usage inside the worker after receiving a large payload:
const mem = wasmExports!.memory as WebAssembly.Memory;
growIfNeeded(mem, inputBuffer.byteLength + 65536); // extra page for output
// IMPORTANT: re-acquire typed array view after grow()
const heap = new Uint8Array(mem.buffer);
heap.set(new Uint8Array(inputBuffer), 0);
After Memory.grow(), the underlying ArrayBuffer is replaced. Any Uint8Array, Float32Array, or other typed array view created from the old .buffer is detached. Always re-read mem.buffer after growing to obtain a valid view.
Step 4 β Shared Linear Memory Across Workers
When multiple workers need concurrent access to the same WASM heap, create the memory once with shared: true. This produces a memory backed by a SharedArrayBuffer, which can be passed to every worker.
// main.ts β shared memory setup
const sharedMem = new WebAssembly.Memory({
initial: 64, // 64 Γ 64KB = 4MB
maximum: 256, // 256 Γ 64KB = 16MB
shared: true,
});
const workers = Array.from({ length: navigator.hardwareConcurrency }, (_, i) => {
const w = new Worker(new URL('./wasm-worker.ts', import.meta.url), { type: 'module' });
w.postMessage({
type: 'INIT',
module: compiledModule, // same compiled module
memory: sharedMem, // same shared memory
workerIndex: i,
totalWorkers: navigator.hardwareConcurrency,
});
return w;
});
WebAssembly.Memory({ shared: true }) is backed by SharedArrayBuffer. The page must be served with both Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Without cross-origin isolation, the browser throws at the Memory constructor. See SharedArrayBuffer & Atomics for header configuration details.
Each worker uses Atomics to coordinate access to non-overlapping regions of the shared memory, or to implement a lock on a shared state word:
// wasm-worker.ts β using Atomics to partition work
self.onmessage = ({ data }) => {
if (data.type === 'INIT') {
const { module, memory, workerIndex, totalWorkers } = data;
const imports = { env: { memory } };
WebAssembly.instantiate(module, imports).then(({ instance }) => {
const processPartition = instance.exports.process_partition as (
start: number, end: number
) => void;
// Each worker owns a slice of the pixel buffer
const totalPixels: number = (memory.buffer as SharedArrayBuffer).byteLength / 4;
const chunkSize = Math.ceil(totalPixels / totalWorkers);
const start = workerIndex * chunkSize;
const end = Math.min(start + chunkSize, totalPixels);
processPartition(start, end);
// Signal completion via Atomics
const statusView = new Int32Array(memory.buffer, 0, totalWorkers);
Atomics.store(statusView, workerIndex, 1);
Atomics.notify(statusView, workerIndex, 1);
});
}
};
Step 5 β Imports and Exports Typing
TypeScript does not auto-type WASM exports. The safe pattern is a narrow cast with a runtime guard:
interface WasmExports extends WebAssembly.Exports {
memory: WebAssembly.Memory;
process_frame: (inputPtr: number, width: number, height: number) => number;
process_partition: (start: number, end: number) => void;
alloc: (byteLen: number) => number;
dealloc: (ptr: number, byteLen: number) => void;
}
function assertExports(e: WebAssembly.Exports): asserts e is WasmExports {
const required = ['memory', 'process_frame', 'alloc', 'dealloc'];
for (const name of required) {
if (!(name in e)) throw new Error(`WASM module missing export: "${name}"`);
}
}
// After instantiation:
assertExports(instance.exports);
const exports = instance.exports; // narrowed to WasmExports
Numeric types crossing the WASM boundary are always i32, i64, f32, or f64 at the ABI level. JS sees i32 and f32 as number, i64 as bigint (when the BigInt integration is enabled in your toolchain). Ensure your WASM toolchain (Emscripten, wasm-pack, or raw wasm32-unknown-unknown) is configured to match the TypeScript types you expect.
If your WASM module manages its own heap (Rust's wasm_bindgen, C++ emscripten, or a custom allocator), export alloc/dealloc functions and use them to reserve addresses before writing input data. Bypassing the allocator by writing to arbitrary offsets corrupts the module's internal data structures.
Data-Transfer Strategy
Choosing how to move data between the main thread and the WASM worker depends on payload size and access pattern:
| Strategy | Latency | Use when |
|---|---|---|
| Structured clone (default postMessage) | 10β20ms per 10MB | Small payloads < 100KB; infrequent messages |
Transferable ArrayBuffer |
< 0.5ms regardless of size | One-way large payloads; worker processes then returns |
Shared WebAssembly.Memory |
0ms (no copy) | Multiple workers on same data; ongoing read/write coordination |
| Pre-allocated WASM heap write | 0ms copy (write in-place) | Input already in WASM memory from prior step |
For image and audio workloads, the recommended pattern is: transfer input ArrayBuffer into the worker β write into WASM linear memory β run computation β transfer result ArrayBuffer back. This avoids all intermediate copies.
// main.ts β full round-trip with transfer semantics
async function processImage(pixels: ArrayBuffer): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const msgId = crypto.randomUUID();
worker.postMessage(
{ type: 'RUN', id: msgId, inputBuffer: pixels },
[pixels] // transfer: pixels.byteLength is now 0 here
);
worker.onmessage = ({ data }) => {
if (data.type === 'RESULT' && data.id === msgId) resolve(data.buffer);
if (data.type === 'ERROR' && data.id === msgId) reject(new Error(data.message));
};
});
}
Verification and Measurement
Profile in Chrome DevTools > Performance > Worker thread lane to confirm the WASM execution is off the main thread. Use performance.now() inside the worker to bracket the critical path:
// wasm-worker.ts β instrumented execution
self.onmessage = ({ data }) => {
if (data.type === 'RUN') {
const t0 = performance.now();
runCompute(data.inputBuffer, data.width, data.height);
const durationMs = performance.now() - t0;
// Include timing in the response for monitoring dashboards
self.postMessage({ type: 'RESULT', buffer: resultBuf, durationMs }, [resultBuf]);
}
};
Realistic throughput figures to calibrate expectations:
| Workload | Pure JS | WASM (scalar) | WASM + SIMD |
|---|---|---|---|
| 4K RGBA blur (3Γ3) | ~180ms | ~55ms | ~14ms |
| 1M-row float32 dot product | ~95ms | ~28ms | ~8ms |
| FLAC decode, 4MB file | ~310ms | ~90ms | ~40ms |
| JSON-like binary decode, 2MB | ~40ms | ~18ms | N/A |
Failure Modes
Module fails to compile. compileStreaming rejects if the WASM binary is malformed, truncated, or has an unsupported feature bit (e.g. exceptions, GC, or threads that the runtime has not enabled). Always .catch() the promise and report the error clearly β the rejection message names the validation failure.
Memory out of range. Passing a pointer outside the current memory size to a WASM function results in a RangeError: WebAssembly.Memory: grow failed. Guard with growIfNeeded before every write.
Import mismatch. If the WASM module declares an import that is absent from the imports object, instantiation throws LinkError: WebAssembly.instantiate(): Import #N module "env" function "xyz" error: function import requires a callable. The error message is precise β check the toolchainβs linker output for required import names.
Typed array detach after grow. The most common silent bug. A grow() call replaces memory.buffer; any view obtained from the previous buffer silently reads zeros or stale data on Chrome and throws on Safari. Re-read mem.buffer after every grow.
SharedArrayBuffer construction fails. If the page is not cross-origin isolated, new WebAssembly.Memory({ shared: true }) throws TypeError: SharedArrayBuffer is not defined (or similar). Verify the response headers with DevTools > Network > document request.
Browser Compatibility
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
WebAssembly.instantiateStreaming |
61 | 58 | 15 | 16 |
WebAssembly.Module structured clone |
61 | 52 | 15 | 16 |
WebAssembly.Memory in worker |
57 | 52 | 15 | 16 |
WebAssembly.Memory({ shared: true }) |
74 | 79 | 15.2 | 79 |
| WASM SIMD (v128) | 91 | 89 | 16.4 | 91 |
| WASM Threads proposal | 74 | 79 | 15.2 | 79 |
Safari requires macOS 12 / iOS 15.2 for shared memory. instantiateStreaming requires application/wasm MIME in all browsers listed above.