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.

Prerequisites

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 });
})();
Compile cost vs. instantiate cost

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]);
}
MIME type is not optional

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:

  1. Decide where in linear memory each input payload lives (e.g. at offset 0 for a single-threaded worker).
  2. Copy or write the input to that address.
  3. Call the WASM export that processes it.
  4. 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);
grow() invalidates existing views

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;
});
COOP / COEP required for shared WebAssembly memory

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.

Allocator exports

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.

WebAssembly compile-once, instantiate-many pipeline The main thread compiles the WASM binary once, then distributes the compiled Module via postMessage to multiple workers, each of which instantiates cheaply and runs compute in parallel. Main Thread fetch('/wasm/compute.wasm') network / cache compileStreaming() 10–50ms (one time) WebAssembly.Module structured-cloneable postMessage (Module) Worker A instantiate() β€” <1ms WASM compute Worker B instantiate() β€” <1ms WASM compute Worker C instantiate() β€” <1ms WASM compute Result ArrayBuffer transfer
Compile once on the main thread; postMessage the compiled WebAssembly.Module to N workers; each instantiates cheaply and runs compute in parallel. Results return as transferred ArrayBuffers.

Frequently Asked Questions

Can I pass a compiled WebAssembly.Module across postMessage?
Yes. A WebAssembly.Module is structured-cloneable, so you can compile it once on the main thread (or in one worker) and postMessage it to multiple workers. Each worker receives its own instance without re-downloading or re-compiling the binary, which is a meaningful win for modules larger than a few hundred kilobytes.
Does WebAssembly run faster inside a worker than on the main thread?
Throughput is identical β€” the same JIT optimisations apply. The benefit is isolation: a 200ms WASM computation that runs on a worker does not block layout or input, whereas the same computation on the main thread drops every frame for those 200ms.
What headers are required for shared WebAssembly memory?
A WebAssembly.Memory created with shared: true is backed by a SharedArrayBuffer. The page must be served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Without those headers, the SharedArrayBuffer constructor throws and the shared memory cannot be created.
How do I supply import objects (e.g. console.log) to a WASM module inside a worker?
Pass an imports object as the second argument to WebAssembly.instantiate or WebAssembly.instantiateStreaming. Inside a worker you have access to self, performance, crypto, and any API that doesn’t touch the DOM. Functions in the imports object become WASM-callable host functions.

See also