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' }));
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-originCross-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.