Sharing Memory Between Wasm and Web Workers

This guide shows exactly how to give a Web Worker live access to the same linear memory your main thread holds — so a value written on one side is immediately visible on the other, with no copy — by creating a shared WebAssembly.Memory, posting it across, and instantiating the module against it on both sides.

The problem this solves is specific. A normal postMessage either copies the data (structured clone) or moves ownership of it (transferable), and neither lets both threads keep reading and writing the same bytes concurrently. Shared memory does: one buffer, many live views. That is the mechanism every threaded Wasm program — a wasm-bindgen-rayon parallel iterator, an Emscripten -pthread build, a hand-rolled worker pool — relies on, so getting this single hand-off right is the foundation for all of them.

Prerequisites

  • [ ] A page served cross-origin isolated: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Check self.crossOriginIsolated === true.
  • [ ] A browser with SharedArrayBuffer and Wasm threads (Chrome 92+, Firefox 95+, Safari 15.2+).
  • [ ] A .wasm module that imports its memory rather than defining it internally, so both instances can be wired to the same one.
  • [ ] A bundler or dev server that serves worker.js from the same origin.

Build the module so memory is imported

For two instances to share one memory, the module must declare its memory as an import (shared) rather than defining and exporting a private one. In WAT that looks like this:

(module
  ;; import a shared memory from the host instead of defining our own
  (import "env" "memory" (memory 256 256 shared))
  (func (export "bump") (param $i i32) (result i32)
    ;; atomically add 1 to the i32 at byte offset (i*4) and return the previous value
    (i32.atomic.rmw.add (i32.mul (local.get $i) (i32.const 4)) (i32.const 1))))

The shared flag on the memory import and the 256 256 (min/max pages, where each page is 64 KiB) are what mark this memory as a SharedArrayBuffer-backed one. Compiled from Rust or C, the same effect comes from the +atomics,+bulk-memory target features or Emscripten’s -pthread.

Why import rather than define-and-export the memory? Because the host has to be the one that creates the single shared instance and hands it to every thread. If each module defined its own internal memory, every instantiation would mint a fresh, private memory — defeating the entire point. By importing, the module declares “give me a memory shaped like this” and the host satisfies that import with the same WebAssembly.Memory object on every thread. The min/max page counts in the import are a contract: the host must supply a memory whose limits match (or are accepted by) what the module declared, and crucially the shared flag on both sides must agree, or instantiation fails with a LinkError.

Step-by-step procedure

  1. Verify isolation before touching shared memory. If this is false, SharedArrayBuffer is not defined and the rest fails.

    if (!self.crossOriginIsolated) {
      throw new Error("Not cross-origin isolated — set COOP/COEP headers.");
    }
  2. Create one shared memory on the main thread. shared: true is what makes buffer a SharedArrayBuffer.

    const memory = new WebAssembly.Memory({ initial: 256, maximum: 256, shared: true });
  3. Compile the module once and post both the compiled WebAssembly.Module and the memory to the worker. A compiled module is structured-cloneable, and the shared buffer is shared, not copied.

    const module = await WebAssembly.compileStreaming(fetch("/bump.wasm"));
    const worker = new Worker("./worker.js", { type: "module" });
    worker.postMessage({ module, memory });
  4. Instantiate on the main thread against that same memory, using it as the import the module expects.

    const main = await WebAssembly.instantiate(module, { env: { memory } });
    const view = new Int32Array(memory.buffer); // both threads will view this same buffer
  5. Instantiate on the worker with the received memory. Both instances now share one linear memory.

    // worker.js
    self.onmessage = async ({ data: { module, memory } }) => {
      const inst = await WebAssembly.instantiate(module, { env: { memory } });
      const view = new Int32Array(memory.buffer);
    
      // Read what the main thread wrote at index 5, then bump it.
      console.log("worker sees:", Atomics.load(view, 5));
      inst.exports.bump(5);                       // atomic add inside Wasm
      self.postMessage({ done: true });
    };
  6. Write from the main thread and observe the worker’s update. Because the buffer is shared, the write is visible to the worker without any message carrying the data.

    Atomics.store(view, 5, 41);                   // main thread writes 41 at index 5
    worker.postMessage({});                        // signal the worker to read + bump
    worker.onmessage = () => {
      console.log("after worker bump:", Atomics.load(view, 5)); // 42
    };

Expected output

With the page correctly isolated, the console shows both threads observing the same memory:

worker sees: 41
after worker bump: 42

The worker read the 41 the main thread stored, ran the in-Wasm atomic increment, and the main thread then read back 42 from the same Int32Array over the same SharedArrayBuffer — proof there was one memory, not two.

To make the shared-versus-copied distinction unmistakable, you can assert it directly. With a shared memory, memory.buffer is a SharedArrayBuffer; with a non-shared one it is a plain ArrayBuffer. And because both sides hold views over the same backing store, a write on either side is visible on the other without any message carrying the value — the postMessage calls in this example carry only signals (“read now”, “done”), never the data itself:

console.log(memory.buffer instanceof SharedArrayBuffer); // true on both threads

If you ever see false here, you created the memory without shared: true and every “shared” write is silently landing in a private copy.

Gotchas

ReferenceError: SharedArrayBuffer is not defined. The page is not cross-origin isolated. Add Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, reload, and re-check self.crossOriginIsolated. A single embedded resource lacking Cross-Origin-Resource-Policy will break isolation for the whole document — see configuring COOP/COEP headers.

The worker gets a copy, not the shared buffer. This happens when you create the memory without shared: true, or when you post memory.buffer.slice(0) or a transferable instead of the memory object. Post the WebAssembly.Memory (or its SharedArrayBuffer) directly; structured clone of a SharedArrayBuffer shares the backing store rather than copying it.

LinkError: imported memory must be shared / imported memory is not shared. The module’s memory import and the memory you pass must agree on the shared flag. A module compiled with atomics expects a shared memory; pass one created with { shared: true }.

Stale views after growing. If you call memory.grow(n), the SharedArrayBuffer is not detached, but your existing Int32Array still only spans the old length. Re-create the view (new Int32Array(memory.buffer)) to see the new pages. This bites hardest across threads: thread A grows the memory, but thread B’s views were created over the old length and silently cannot address the new region until B re-creates them too. Either size maximum to your worst case and never grow, or broadcast a “re-create your views” signal after any grow.

Wrong typed array for atomics. Atomics.load/store and especially Atomics.wait/notify are defined on integer typed arrays, with wait/notify requiring an Int32Array (or BigInt64Array). If you build a Float64Array over the shared buffer and try Atomics.wait on it, you get a TypeError. Keep a dedicated Int32Array for your synchronization slots even if you view the payload region through another typed array.

Performance note

Posting a shared memory is O(1) no matter how large it is: structured-cloning a SharedArrayBuffer shares the backing store, so handing a worker a 256 MiB working set costs effectively nothing, versus a structured copy that would memcpy all 256 MiB and allocate a second buffer the GC must later reclaim. For large, long-lived buffers this is the entire reason to use shared memory — the data-transfer cost collapses from linear to constant.

Put concrete numbers on it: a structured clone copies at roughly memory bandwidth, on the order of 10 GB/s, so cloning 256 MiB costs around 25 ms — per postMessage, every time you hand the buffer over. Sharing the same buffer is a pointer hand-off measured in microseconds and incurs no GC pressure. If your workers exchange the working set repeatedly (a render loop, a streaming decoder), that difference compounds into the dominant cost. The flip side is that you have traded a copy you did not have to think about for a synchronization protocol you do — which is the right trade for large buffers and the wrong one for a few kilobytes, where a plain postMessage copy is simpler and the headers are not worth the trouble.

Frequently Asked Questions

Can I share memory with a worker without compiling the module twice? You compile once. Pass the single WebAssembly.Module (it is cloneable) to the worker and call WebAssembly.instantiate(module, imports) on each side. Only instantiation happens per thread; compilation is shared.

Do both instances need the exact same module? They need to agree on the memory import, but they can be different modules as long as each declares the same shared memory import shape. In practice you almost always instantiate the identical module on every thread.

Is Atomics required, or can I use plain Int32Array indexing? Plain indexing works for reads/writes that are never concurrent, but any location more than one thread touches must use Atomics.load/Atomics.store to avoid torn or reordered reads. See using Atomics for Wasm thread synchronization.

← Back to SharedArrayBuffer, Atomics & Threading