Sharing WebAssembly Linear Memory Across Workers
One WebAssembly.Memory object, created with shared: true and handed to every worker in a pool, so that eight threads read and write the same linear memory with no postMessage copy anywhere in the hot path.
This is the multi-threaded case of WebAssembly in Workers, which sits inside the High-Performance Computation Patterns reference. The parent guide covers the single-worker pipeline — compile once, distribute the compiled module, copy data in and out of linear memory. This page removes that copy: the memory becomes the transport. Everything about the coordination primitives is inherited from SharedArrayBuffer & Atomics, because at the JavaScript level memory.buffer on a shared memory is a SharedArrayBuffer and behaves like one in every respect.
What the Shared Flag Actually Changes
Passing shared: true is not a hint. It changes the type of the backing buffer, adds a link-time constraint on the module, makes maximum mandatory, and changes what grow() does.
shared: false (default) |
shared: true |
|
|---|---|---|
memory.buffer |
ArrayBuffer |
SharedArrayBuffer |
| Cross-origin isolation | Not required | Required, or the constructor throws |
maximum option |
Optional | Mandatory |
| Crossing a thread boundary | Structured clone copies the bytes | Handle is cloned; bytes stay in one place |
grow() |
Detaches the old buffer; every existing view becomes zero-length | Never detaches; length grows in place, capped at maximum |
Atomics on a view |
TypeError for wait, allowed for the rest |
Full Atomics surface including wait/notify |
| Module memory import | Must be declared unshared | Must be declared shared, or LinkError |
The maximum requirement is the one that surprises people first. The engine has to reserve the whole virtual address range up front, because it can never move the memory to a new location the way it can for unshared memory — other threads hold live pointers into it. Omitting it produces TypeError: WebAssembly.Memory(): maximum is required for shared memory.
grow() and whether the constructor runs at all.Minimal Reproducible Example
Three files: a layout module that both sides import so the byte offsets can never drift apart, the main thread that allocates and distributes, and the worker that instantiates and then parks on an atomic wait.
// shared-layout.ts — imported by BOTH the main thread and every worker.
// The single source of truth for the byte layout of the shared memory.
export const PAGE_BYTES = 65_536;
export const INITIAL_PAGES = 128; // 8 MiB
export const MAX_PAGES = 512; // 32 MiB ceiling
/** Control block: an Int32Array at offset 0 that only Atomics touch. */
export const CTRL_BYTE_OFFSET = 0;
export const CTRL_INTS = 64; // 256 bytes reserved
/** Slot indices, spaced 64 bytes (16 int32s) apart to avoid false sharing. */
export const GEN = 0; // run generation — incremented to publish work
export const DONE = 16; // number of workers finished with the current run
/** Data region starts on a cache-line boundary after the control block. */
export const DATA_BYTE_OFFSET = CTRL_INTS * Int32Array.BYTES_PER_ELEMENT; // 256
// main.ts — allocate the shared memory and drive the pool.
import {
INITIAL_PAGES, MAX_PAGES, PAGE_BYTES,
CTRL_BYTE_OFFSET, CTRL_INTS, GEN, DONE, DATA_BYTE_OFFSET,
} from './shared-layout';
if (!crossOriginIsolated) {
throw new Error(
'Not cross-origin isolated — serve this document with ' +
'COOP: same-origin and COEP: require-corp.'
);
}
const memory = new WebAssembly.Memory({
initial: INITIAL_PAGES,
maximum: MAX_PAGES, // mandatory when shared
shared: true,
});
// Compile once on the main thread; every worker links against this artefact.
const module = await WebAssembly.compileStreaming(
fetch('/wasm/parallel-compute.wasm')
);
const WORKER_COUNT = Math.min(navigator.hardwareConcurrency ?? 4, 8);
const ctrl = new Int32Array(memory.buffer, CTRL_BYTE_OFFSET, CTRL_INTS);
const data = new Float32Array(
memory.buffer,
DATA_BYTE_OFFSET,
(INITIAL_PAGES * PAGE_BYTES - DATA_BYTE_OFFSET) / Float32Array.BYTES_PER_ELEMENT
);
const workers = Array.from({ length: WORKER_COUNT }, (_, workerIndex) => {
const w = new Worker(new URL('./compute-worker.ts', import.meta.url), { type: 'module' });
// Both the Module and the Memory are structured-cloneable; cloning a shared
// memory copies the handle, not the 8 MiB behind it.
w.postMessage({ type: 'INIT', module, memory, workerIndex, totalWorkers: WORKER_COUNT });
return w;
});
await Promise.all(workers.map(w => new Promise<void>(resolve => {
w.addEventListener('message', function onReady(e: MessageEvent) {
if (e.data?.type === 'READY') { w.removeEventListener('message', onReady); resolve(); }
});
})));
/** Fill the data region in place — no message, no copy. */
data.fill(Math.PI);
/** Publish a run and wait for the whole pool to report back. */
async function runOnce(): Promise<number> {
const t0 = performance.now();
Atomics.store(ctrl, DONE, 0);
Atomics.add(ctrl, GEN, 1); // publish
Atomics.notify(ctrl, GEN); // wake every parked worker
// Atomics.wait would throw here: the main thread's agent cannot block.
while (Atomics.load(ctrl, DONE) < WORKER_COUNT) {
const observed = Atomics.load(ctrl, DONE);
if (observed >= WORKER_COUNT) break;
await Atomics.waitAsync(ctrl, DONE, observed).value;
}
return performance.now() - t0;
}
console.log(`pass 1: ${(await runOnce()).toFixed(2)} ms`);
console.log(`pass 2: ${(await runOnce()).toFixed(2)} ms`);
/// <reference lib="webworker" />
// compute-worker.ts — instantiate against the shared memory, then park on Atomics.wait.
import {
CTRL_BYTE_OFFSET, CTRL_INTS, GEN, DONE, DATA_BYTE_OFFSET,
} from './shared-layout';
declare const self: DedicatedWorkerGlobalScope;
interface InitMsg {
type: 'INIT';
module: WebAssembly.Module;
memory: WebAssembly.Memory;
workerIndex: number;
totalWorkers: number;
}
/** compute_partition takes BYTE offsets into linear memory, half-open. */
type ComputePartition = (startByte: number, endByte: number) => void;
self.onmessage = async ({ data }: MessageEvent<InitMsg>) => {
if (data.type !== 'INIT') return;
const { module, memory, workerIndex, totalWorkers } = data;
// The module declares (import "env" "memory" (memory 128 512 shared)).
const instance = await WebAssembly.instantiate(module, { env: { memory } });
const compute = instance.exports.compute_partition as ComputePartition;
self.postMessage({ type: 'READY', workerIndex });
runLoop(memory, compute, workerIndex, totalWorkers); // never returns
};
function runLoop(
memory: WebAssembly.Memory,
compute: ComputePartition,
workerIndex: number,
totalWorkers: number,
): never {
const ctrl = new Int32Array(memory.buffer, CTRL_BYTE_OFFSET, CTRL_INTS);
let seen = Atomics.load(ctrl, GEN);
for (;;) {
// Blocks this worker's event loop until the coordinator bumps GEN.
Atomics.wait(ctrl, GEN, seen);
seen = Atomics.load(ctrl, GEN);
// Re-read byteLength every pass: another thread may have grown the memory.
const dataBytes = memory.buffer.byteLength - DATA_BYTE_OFFSET;
const elements = Math.floor(dataBytes / Float32Array.BYTES_PER_ELEMENT);
const chunk = Math.ceil(elements / totalWorkers);
const startEl = workerIndex * chunk;
const endEl = Math.min(startEl + chunk, elements);
compute(
DATA_BYTE_OFFSET + startEl * Float32Array.BYTES_PER_ELEMENT,
DATA_BYTE_OFFSET + endEl * Float32Array.BYTES_PER_ELEMENT,
);
// Publish completion. The adder that takes the count to totalWorkers wakes
// the coordinator; the others stay silent.
if (Atomics.add(ctrl, DONE, 1) === totalWorkers - 1) {
Atomics.notify(ctrl, DONE);
}
}
}
The binary’s memory import has to match on the WebAssembly side as well — the shared flag lives in the module’s type section, not only in the JavaScript object:
(module
(import "env" "memory" (memory 128 512 shared))
(func (export "compute_partition") (param $startByte i32) (param $endByte i32)
;; loops over [$startByte, $endByte) with f32.load / f32.store
)
)
WebAssembly.Memory({ shared: true }) is backed by SharedArrayBuffer, so the document must be cross-origin isolated. Serve it with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and make every cross-origin image, font and script CORP-eligible or the page silently loses isolation. Guard with if (!crossOriginIsolated) before constructing the memory; if the guard fires, read Debugging SharedArrayBuffer Cross-Origin Errors rather than rewriting the JavaScript.
workerIndex, two workers cannot address the same byte even if the partition count changes.Line-by-Line Walkthrough
shared-layout.ts as a single import. The offsets are the wire format between agents. Duplicating them as literals in the worker is how the control block ends up overlapping the data region — an off-by-one in one file that corrupts flags in another and produces a hang rather than an exception. One module, imported by both sides, makes the layout a compile-time contract. The bundler wiring that keeps this working for module workers is covered in Bundling Module Workers with Vite and webpack.
w.postMessage({ module, memory, … }) with no transfer list. Both WebAssembly.Module and WebAssembly.Memory are serializable, and neither is transferable. For a shared memory, structured clone duplicates the handle and both agents end up pointing at the same pages, which is precisely why there is no transfer list and no byteLength of 0 afterwards. This is the opposite of the ownership handoff described in Transferable Objects & Zero-Copy: nobody gives anything up.
{ env: { memory } } in the imports object. The binary declares (import "env" "memory" …), so supplying the externally created memory satisfies that import and the instance uses the shared buffer as its linear memory instead of allocating a private one. Every worker instantiates its own Instance — instances are per-agent and are not serializable — but all of those instances address the same bytes. The instantiation call itself, and the LinkErrors it can produce, are worked through in Instantiating WebAssembly Modules Inside Workers.
Atomics.add(ctrl, GEN, 1) then Atomics.notify(ctrl, GEN). The generation counter is the publish operation. A plain ctrl[GEN]++ would be a read-modify-write that two agents can interleave; Atomics.add is a single sequentially consistent operation. notify then wakes the workers parked on that index. Ordinary writes into the data region that happen before the atomic publish are visible to any thread that observes the new generation — that ordering edge is the entire reason the flag is atomic and the bulk data does not have to be.
Atomics.wait(ctrl, GEN, seen) in the worker. The worker blocks its own agent until ctrl[GEN] stops equalling seen. The comparison is what makes it race-free: if the coordinator bumps the counter between the previous load and the wait, the values already differ and wait returns not-equal immediately instead of sleeping through a run. Blocking here is deliberate — the loop costs no CPU while idle and wakes in tens of microseconds, versus a postMessage round trip which must queue a task on the worker’s event loop. The trade-off is real and is the first entry in the gotchas below.
Atomics.add(ctrl, DONE, 1) === totalWorkers - 1. Atomics.add returns the value before the addition, so the worker that sees totalWorkers - 1 is the one that just made the count complete. Only it calls notify, so a pool of eight produces one wake-up instead of eight. The same “last one out” idiom drives the drain protocols in Coordinating Workers with Atomics.wait and notify.
await Atomics.waitAsync(ctrl, DONE, observed).value on the main thread. Atomics.wait throws TypeError: Atomics.wait cannot be called in this context on the main thread, because the window agent is not allowed to block. waitAsync returns { async: boolean, value } and never blocks; it resolves with 'ok', 'not-equal' or 'timed-out'. Passing the value just observed, inside a loop that re-checks the condition, closes the window where the pool finishes between the load and the wait. waitAsync is available in Chrome 87+, Safari 16.4+ and Firefox 116+; where it is missing, fall back to a DONE message posted by the last worker.
READY the message channel goes quiet for good: every signal in the loop is an atomic operation on 256 bytes of the memory the workers are already reading.Gotchas and Edge Cases
A blocked worker cannot receive messages. While a worker sits in Atomics.wait, its event loop is stopped: postMessage payloads queue up undelivered, onmessage never fires, and any promise scheduled inside it stays pending. That is fine for a worker whose only job is to run partitions, which is why the example switches to the control block as the sole signalling channel after READY. It is a disaster for a worker that also handles configuration or cancellation messages. Either keep a strict split — compute workers block, coordinator workers do not — or use a bounded Atomics.wait(ctrl, GEN, seen, 50) so the loop surfaces every 50 ms to drain its message queue. worker.terminate() still works on a blocked worker; a graceful shutdown flag in the control block plus a notify is cleaner, as covered in Handling Worker Termination Gracefully in SPAs.
Atomics only works on integer views, and only on aligned indices. Atomics.store(new Float32Array(memory.buffer), 0, 1) throws TypeError: [object Float32Array] is not an int typed array — the allowed views are Int8Array, Uint8Array, Int16Array, Uint16Array, Int32Array, Uint32Array, BigInt64Array and BigUint64Array, and wait/notify narrow that further to Int32Array and BigInt64Array. The byte offset of the view must also satisfy the element alignment: new Int32Array(memory.buffer, 6, 4) throws a RangeError because 6 is not a multiple of 4. This is why the control block lives at offset 0 with its own reserved region, rather than being wedged in beside float data at whatever offset happens to be free. If a run needs to publish float results atomically, publish an integer sequence number and let readers infer that the floats before it are complete.
Sharing a memory adds no locking whatsoever. Two threads writing overlapping byte ranges is a data race with an unspecified outcome, and WebAssembly will not warn you: no bounds violation, no trap, just wrong numbers that vary between runs and between machines. Partition the address space so each worker owns a disjoint half-open range — the example derives its range purely from workerIndex, so overlaps are impossible by construction. When ranges genuinely must overlap, build a lock out of the control block instead of hoping, as in Building a Lock-Free Ring Buffer with Atomics. Note also that the WebAssembly memory model gives sequential consistency only to explicit atomic instructions; ordinary f32.store operations are unordered relative to each other across threads.
Growing is safe but not free of surprises. memory.grow(pages) on a shared memory does not detach anything — SharedArrayBuffer has no detach operation, so no thread is left holding a zero-length view. In current engines the buffer is a growable SharedArrayBuffer and byteLength increases in place, so a length-tracking view (new Uint8Array(memory.buffer), no explicit length) sees the new size while an explicitly sized view keeps addressing its original range and never learns about the new pages. Two practical rules follow: re-read memory.buffer and rebuild sized views after any call that might grow — including calls into the module, since an internal allocation can grow memory without your JavaScript asking — and treat the moment of growth as a synchronisation point, because other threads observe the new size at an unpredictable point relative to their own work. The example re-reads memory.buffer.byteLength at the top of every pass for exactly this reason. Growth also fails silently in the sense that exceeding maximum returns -1 rather than throwing, so check the return value.
Performance Note
Structured-cloning a 10 MiB ArrayBuffer through postMessage costs roughly 8–20 ms on a 2023-class laptop (Chrome 124), because the bytes are copied twice — once out, once in — at somewhere near 1 GB/s. Transferring it instead is around 0.1 ms, but transfer moves ownership: exactly one agent can hold it, which is useless when eight workers need concurrent read access to the same array. Shared memory costs zero per pass: the data is already in place, and the only per-run overhead is the coordination. An Atomics.notify wake-up lands in the 10–60 µs range, so a pool of eight pays well under half a millisecond per generation regardless of how large the memory is.
The crossover is therefore not about size but about access pattern. A pipeline where each stage owns the data and hands it on should transfer — see postMessage vs SharedArrayBuffer: When to Choose Each for the decision in full. A fan-out where every worker reads the same buffer, or an iterative solver that runs hundreds of passes over one grid, should share: the copy cost would otherwise be paid on every pass, and at 60 passes per second a 10 MiB clone alone exceeds the frame budget several times over.
One measurable trap once the copies are gone: false sharing. Two workers writing to different Int32Array slots that land on the same 64-byte cache line force that line to bounce between cores, and a completion counter hammered by eight threads can cost more than the work it is counting. That is why GEN and DONE sit 16 int32s apart in the layout module. Padding per-worker slots to a full cache line typically recovers 20–40% on counter-heavy loops on x86-64 — verify it on your own workload by timing the same pass with a stride of 1 versus a stride of 16:
// Run both strides over the same shared memory and compare.
const t0 = performance.now();
await runOnce();
console.log(`pass: ${(performance.now() - t0).toFixed(2)} ms`);
Finally, size the pool to navigator.hardwareConcurrency and stop — a shared memory does not change the fact that oversubscribed threads contend for the same cores, and the sizing heuristics in Worker Pool Management apply unchanged. Once the pool is saturated, the next throughput gain comes from the instruction level rather than the thread level: see Using SIMD in Worker Threads.