Sharing WebAssembly Linear Memory Across Workers

Shared WebAssembly linear memory lets multiple worker threads read and write the same byte array without any postMessage copy. This page is a deep-dive into WebAssembly in Workers and part of the High-Performance Computation Patterns reference. It assumes familiarity with SharedArrayBuffer & Atomics because the coordination primitives are identical — the shared WASM memory buffer is, at the JavaScript level, exactly a SharedArrayBuffer.

Minimal Reproducible Example

// main.ts — create shared memory and distribute it to workers

// STEP 1: Check cross-origin isolation (required for SharedArrayBuffer)
if (!crossOriginIsolated) {
  throw new Error(
    'Page is not cross-origin isolated. ' +
    'Serve with COOP: same-origin + COEP: require-corp.'
  );
}

// STEP 2: Allocate shared WASM memory
const sharedMemory = new WebAssembly.Memory({
  initial: 128,   // 128 pages × 64KB = 8MB initial
  maximum: 512,   // 512 pages × 64KB = 32MB ceiling
  shared: true,   // backed by SharedArrayBuffer
});

// STEP 3: Compile the module once
const module = await WebAssembly.compileStreaming(fetch('/wasm/parallel-compute.wasm'));

const WORKER_COUNT = navigator.hardwareConcurrency;

// STEP 4: Spin up workers, passing the same memory instance to each
const workers = Array.from({ length: WORKER_COUNT }, (_, index) => {
  const w = new Worker(new URL('./parallel-worker.ts', import.meta.url), { type: 'module' });
  w.postMessage({
    type: 'INIT',
    module,
    memory: sharedMemory,
    workerIndex: index,
    totalWorkers: WORKER_COUNT,
  });
  return w;
});

// STEP 5: Wait for all workers to signal READY
let readyCount = 0;
await new Promise<void>(resolve => {
  workers.forEach(w => {
    w.onmessage = ({ data }) => {
      if (data.type === 'READY') {
        readyCount++;
        if (readyCount === WORKER_COUNT) resolve();
      }
    };
  });
});

// STEP 6: Write input into the shared buffer from the main thread
const inputView = new Float32Array(sharedMemory.buffer, 0, 1_000_000);
inputView.fill(Math.PI); // example: fill 4MB with π

// STEP 7: Trigger parallel computation
workers.forEach((w, i) => w.postMessage({ type: 'RUN', runId: 'batch-1' }));
COOP / COEP required for shared WebAssembly memory

WebAssembly.Memory({ shared: true }) is backed by SharedArrayBuffer. Without cross-origin isolation the constructor throws TypeError: SharedArrayBuffer is not defined (or an equivalent message). Serve your document with both headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Check the result in JavaScript with if (!crossOriginIsolated) before constructing shared memory.

Worker Implementation

// parallel-worker.ts
interface InitMsg {
  type: 'INIT';
  module: WebAssembly.Module;
  memory: WebAssembly.Memory;
  workerIndex: number;
  totalWorkers: number;
}
interface RunMsg { type: 'RUN'; runId: string }
type Msg = InitMsg | RunMsg;

// Persistent across messages
let wasmMemory: WebAssembly.Memory;
let computePartition: (start: number, end: number) => void;
let workerIndex: number;
let totalWorkers: number;

// Coordination: first 4 × totalWorkers bytes are a status Int32Array
// Index i = 0 → worker i idle; 1 → done with current run
const STATUS_BYTE_OFFSET = 0;

self.onmessage = async ({ data }: MessageEvent<Msg>) => {
  if (data.type === 'INIT') {
    wasmMemory  = data.memory;
    workerIndex = data.workerIndex;
    totalWorkers = data.totalWorkers;

    const imports: WebAssembly.Imports = { env: { memory: wasmMemory } };
    const { instance } = await WebAssembly.instantiate(data.module, imports);
    computePartition = instance.exports.compute_partition as
      (start: number, end: number) => void;

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

  if (data.type === 'RUN') {
    // Reset this worker's status flag
    const status = new Int32Array(wasmMemory.buffer, STATUS_BYTE_OFFSET, totalWorkers);
    Atomics.store(status, workerIndex, 0);

    // Compute the range of elements this worker owns
    // Data starts after the status region: totalWorkers × 4 bytes
    const dataByteOffset = totalWorkers * Int32Array.BYTES_PER_ELEMENT;
    const totalElements = (wasmMemory.buffer.byteLength - dataByteOffset) / Float32Array.BYTES_PER_ELEMENT;
    const chunkSize = Math.ceil(totalElements / totalWorkers);
    const start = workerIndex * chunkSize;
    const end   = Math.min(start + chunkSize, totalElements);

    // Run WASM computation on the assigned slice
    computePartition(start, end);

    // Signal completion
    Atomics.store(status, workerIndex, 1);
    Atomics.notify(status, workerIndex, 1);

    self.postMessage({ type: 'DONE', runId: data.runId, workerIndex });
  }
};

Line-by-Line Walkthrough

new WebAssembly.Memory({ initial, maximum, shared: true }): The shared flag is what triggers the SharedArrayBuffer backing. Without it, the memory uses a regular ArrayBuffer that cannot be shared. The maximum field is required when shared: true; omitting it throws a TypeError.

Passing memory to each worker: The WebAssembly.Memory object is structured-cloneable, so it passes through postMessage intact. Every worker that receives it holds a reference to the same underlying SharedArrayBuffer. A write by Worker A at offset 1000 is immediately visible to Worker B reading at offset 1000 — there is no copy and no message.

{ env: { memory: wasmMemory } } in the imports object: The WASM module declares (import "env" "memory" (memory ...)) in its binary. Passing the externally-created memory object satisfies that import and causes the WASM module to use the shared buffer as its linear memory rather than allocating a private one.

STATUS_BYTE_OFFSET coordination region: The first totalWorkers × 4 bytes of the shared memory are reserved as an Int32Array status board. Each worker writes 1 to its slot when it finishes its computation. The main thread (or a coordinator worker) can call Atomics.wait or poll Atomics.load to know when all workers are done.

Atomics.store + Atomics.notify: Atomics.store atomically writes the completion flag, preventing a torn write. Atomics.notify wakes any thread that called Atomics.wait on the same index. This is the same mechanism described in SharedArrayBuffer & Atomics — the WASM shared memory buffer is directly usable with Atomics because it is a SharedArrayBuffer.

Gotchas and Edge Cases

Detached views after Memory.grow(): When any thread calls memory.grow(pages), the SharedArrayBuffer backing the memory is resized. On some runtimes (including V8), existing typed-array views (e.g. new Float32Array(wasmMemory.buffer, ...)) are detached. Always re-read wasmMemory.buffer after any operation that may trigger a grow. Growing while other workers are writing into the memory is a data race — coordinate with Atomics to quiesce writers before growing.

maximum is required for shared memory: Attempting new WebAssembly.Memory({ initial: 16, shared: true }) without a maximum throws TypeError: WebAssembly.Memory(): maximum is required for shared memory. Set a ceiling large enough to hold peak working data plus result buffers.

The status region must not overlap data: In the example above, data starts at totalWorkers * 4 bytes. If you later change totalWorkers or add more coordination state, recalculate the data offset. Misalignment causes the WASM code to corrupt the status flags or the JS code to corrupt data.

WASM memory is not automatically thread-safe: Sharing a memory object does not add any locking. Two workers writing to overlapping ranges of the buffer simultaneously produce undefined results. Always partition the address space so each worker owns a disjoint slice, or use Atomics-based locks when writes must be serialised.

Performance Rule of Thumb

A postMessage round-trip for a 10MB ArrayBuffer (structured clone) costs roughly 12–20ms on a modern desktop. Sharing the same 10MB via WebAssembly.Memory({ shared: true }) makes it available to all workers with zero transfer cost — the trade-off is that you must coordinate access with Atomics, adding roughly 0.1–0.5ms of synchronisation overhead per worker per work unit. Shared memory wins decisively when the data is large and workers need overlapping read access; transferable ArrayBuffers win when data flows linearly through a pipeline with clear ownership handoffs.

Frequently Asked Questions

Does WebAssembly.Memory({ shared: true }) require special HTTP headers?
Yes. A shared WebAssembly.Memory 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 cross-origin isolation the browser throws a TypeError at the Memory constructor. Verify the headers in DevTools > Network > document response before debugging the JavaScript.
Can I grow a shared WebAssembly.Memory from inside a worker?
Yes, but with constraints. Any thread can call Memory.grow(), but the grow is atomic — all threads see the new size simultaneously. However, existing typed-array views created from the SharedArrayBuffer become detached after a grow in some runtimes. Always re-read memory.buffer after any operation that may trigger a grow, and do not grow while other workers are actively writing.

See also