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.

What flipping the shared flag changes Two halves. On the left, shared is false: each of three workers points down at its own ArrayBuffer holding a private eight mebibyte copy, because postMessage structured-clones the bytes once per worker. Twenty-four mebibytes are resident for one eight mebibyte dataset, no worker can see another's writes, and grow detaches every existing view. On the right, shared is true: the same three workers all point down at a single WebAssembly.Memory whose buffer is one eight mebibyte SharedArrayBuffer. Only the handle is cloned, so there is one copy of the bytes and three live views onto it, maximum becomes mandatory, grow never detaches, and Atomics.wait plus cross-origin isolation become available. shared: false every agent owns a private copy Worker 1 own copy Worker 2 own copy Worker 3 own copy ArrayBuffer 8 MiB copy ArrayBuffer 8 MiB copy ArrayBuffer 8 MiB copy postMessage copies all 8 MiB, per worker 24 MiB resident for one 8 MiB dataset grow() detaches — old views go zero-length shared: true one buffer, one set of bytes Worker 1 same bytes Worker 2 same bytes Worker 3 same bytes one WebAssembly.Memory memory.buffer — a SharedArrayBuffer, 8 MiB one copy of the bytes, three live views maximum mandatory; grow() never detaches Atomics.wait needs this buffer + COOP/COEP Structured clone duplicates the handle, not the pages — so every worker ends up addressing the same bytes.
The flag is not a hint: it changes the buffer's type, the link-time contract, the meaning of 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
  )
)
COOP / COEP required for shared WebAssembly memory

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.

One address space, one owner per range A single shared WebAssembly.Memory drawn as a horizontal strip. At its low end sits the control block, bytes zero to two hundred and fifty-five, holding the GEN and DONE slots that only Atomics touch. The rest of the strip is the data region, split into four equal partitions covering bytes two hundred and fifty-six to two mebibytes, two to four, four to six and six to eight mebibytes. Above the control block the main thread publishes with Atomics.add on GEN and parks in waitAsync. Below the strip four workers each write bulk float32 data into their own partition and nothing else, while a dashed bus shows every agent reaching only the control block for Atomics.wait, notify and add. Bulk partition writes are ordinary unsynchronised stores, made safe by the ranges being disjoint. one WebAssembly.Memory — initial 128, maximum 512, shared: true 8 MiB of linear memory that every agent addresses identically Main thread Atomics.add(GEN, 1) waitAsync(DONE) control block bytes 0 – 255 GEN DONE partition 0 bytes 256 – 2 MiB worker 0 writes only partition 1 bytes 2 – 4 MiB worker 1 writes only partition 2 bytes 4 – 6 MiB worker 2 writes only partition 3 bytes 6 – 8 MiB worker 3 writes only Worker 0 Atomics.wait(GEN) Atomics.add(DONE) Worker 1 Atomics.wait(GEN) Atomics.add(DONE) Worker 2 Atomics.wait(GEN) Atomics.add(DONE) Worker 3 Atomics.wait(GEN) Atomics.add(DONE) bulk f32 writes Atomics only every Atomics.wait, notify and add targets the control block — never the data partition writes are ordinary unsynchronised f32 stores; disjoint ranges are what make them safe
The layout module is this picture as constants. Because each range is derived from 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.

One pass, from publish to the single wake-up A sequence with three lifelines: the main thread, which cannot block and so uses waitAsync, and two workers that block in Atomics.wait. First the main thread posts the compiled module and the memory to each worker; only the handle is cloned, not the eight mebibytes. Each worker instantiates and replies READY. The main thread then fills the data region directly in linear memory with no message at all, bumps the generation counter with Atomics.add and calls notify, which wakes both parked workers. The workers compute their partitions in parallel and finish at different times. Worker zero's Atomics.add on DONE returns zero, so it is not the last and stays silent. Worker one's returns one, equal to the worker count minus one, so it alone calls notify, and that single wake-up resolves the main thread's waitAsync. Main thread cannot block — waitAsync Worker 0 blocks in Atomics.wait Worker 1 blocks in Atomics.wait postMessage({ module, memory }) …and the same pair to Worker 1 the handle is cloned — the 8 MiB is not READY READY data.fill(Math.PI) straight into linear memory Atomics.add(GEN, 1) · notify one notify wakes both parked workers compute_partition(0) compute_partition(1) Atomics.add(DONE, 1) → 0 not the last — stays silent Atomics.add(DONE, 1) → 1 last one out Atomics.notify(DONE) — the main thread's waitAsync resolves one wake-up per pass, not one per worker — and not one byte copied
After 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.

Four ways a shared linear memory goes wrong Four panels, each pairing a symptom with a fix. First, a blocked worker with a silent inbox: while it sits in Atomics.wait its postMessage payloads queue up undelivered, onmessage never fires and promises created inside it stay pending; the fix is to keep compute workers pure, or to park with a timeout such as Atomics.wait on GEN with fifty milliseconds so the loop surfaces and drains its queue. Second, Atomics on a Float32Array: storing through a float view throws a TypeError, and an unaligned Int32Array view throws a RangeError; the fix is an Int32Array control block at offset zero publishing a sequence number rather than the floats themselves. Third, overlapping partitions: there is no trap and no bounds error, only numbers that differ between runs and between machines; the fix is to derive every range from workerIndex, or to build a lock where ranges genuinely must meet. Fourth, grow and stale sized views: an explicitly sized view keeps its old byteLength and never sees the pages grow added; the fix is to re-read memory.buffer after any call that can grow, and to check the return value because growing past maximum returns minus one instead of throwing. four ways a shared linear memory goes wrong Blocked worker, silent inbox queued, undelivered symptom postMessage payloads queue up; onmessage never fires and promises created inside the worker stay pending fix keep compute workers pure, or park with a timeout — Atomics.wait(ctrl, GEN, seen, 50) drains the queue Atomics on a Float32Array TypeError not an int typed array symptom Atomics.store(new Float32Array(buf), 0, 1) throws, and new Int32Array(buf, 6, 4) is a RangeError fix keep an Int32Array control block at offset 0 and publish a sequence number, never the floats Overlapping partitions ranges overlap symptom no trap, no bounds error — just numbers that differ between runs and between machines fix derive every range from workerIndex so overlap is impossible; where ranges must meet, build a lock grow() and stale sized views sized vs tracking view symptom an explicitly sized view keeps its old byteLength and never sees the pages that grow() added fix re-read memory.buffer after any call that can grow; growing past maximum returns -1 instead of throwing Sharing bytes removes the copy; it does not add a single guarantee about who may touch them.
Three of the four fail silently — no exception, no trap, just a hang or a number that is wrong on someone else's machine.

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.

What one pass over 10 MiB costs, by strategy A horizontal bar chart on a logarithmic axis running from 0.01 to 100 milliseconds. Structured cloning a 10 mebibyte payload costs about 14 milliseconds per pass, because the bytes are copied twice at roughly one gigabyte per second and every pass pays it again. Transferring the same buffer costs about 0.1 milliseconds, but ownership moves, so exactly one agent may hold it. A shared memory with padded control slots costs about 0.05 milliseconds, which is coordination only — nothing is copied. The same shared memory with GEN and DONE on a single 64-byte cache line costs about 0.08 milliseconds for identical work, the penalty of false sharing. Only the shared memory lets all eight workers read the same 10 mebibytes at once. per-pass cost for one 10 MiB payload — log scale order-of-magnitude figures from a 2023-class laptop, Chrome 124 structured clone ≈14 ms copied twice at roughly 1 GB/s — every pass pays it again transfer (ownership) ≈0.1 ms ownership moves: exactly one agent may hold the buffer shared memory ≈0.05 ms nothing is copied; only the wake-up is charged shared + false sharing ≈0.08 ms identical work — GEN and DONE sharing one 64 B line 0.01 ms 0.1 ms 1 ms 10 ms 100 ms Only the shared memory lets all eight workers read the same 10 MiB at once.
Each bar is one pass, so the clone bar is paid again on every iteration of a solver while the shared-memory bars are not. The last two do identical work — the difference is 64 bytes of padding in the layout module.

Frequently Asked Questions

Does WebAssembly.Memory with shared true require special HTTP headers?
Yes. A shared WebAssembly.Memory is backed by a SharedArrayBuffer, so the document must be cross-origin isolated: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, with every cross-origin subresource served CORP-eligible or fetched in CORS mode. Without isolation the Memory constructor throws a TypeError before your code touches a single byte. Guard on crossOriginIsolated and check the document response headers in DevTools → Network before you start debugging JavaScript — the failure is a deployment problem far more often than a code problem.
Can I grow a shared WebAssembly.Memory from inside a worker?
Yes, and unlike unshared memory the grow never detaches anything — a SharedArrayBuffer has no detach operation, so no thread can be left holding a zero-length view. In current engines memory.buffer is a growable SharedArrayBuffer whose byteLength increases in place, so length-tracking views such as new Uint8Array(memory.buffer) see the new size automatically while explicitly sized views keep addressing their old range. The two real constraints are that maximum caps how far you can go, and that other threads observe the new size at an unpredictable moment, so quiesce writers through the control block before growing and re-read memory.buffer afterwards rather than assuming the object identity is stable.

See also