← All articles
JavaScriptWeb WorkersPerformance

When postMessage is too slow: Shared Memory patterns for Web Workers

An overview of Web Worker communication: Comlink for control, transferables for big handoffs, and SharedArrayBuffer patterns for high-frequency updates.

Axel IsouardJul 21, 2026 · 12 min read

Web Workers are one of those APIs that look simple on paper. You spawn a worker, you call postMessage, you listen for replies, and suddenly your heavy work is off the main thread. For a while, that is enough.

Then you start pushing real-time data across that boundary. Player input at 60 Hz. Transform updates every frame. Meters, flags, timestamps. Suddenly the worker is fine, but the pipe between threads is the bottleneck. Structured cloning, message queuing, and wake-ups add up fast when you are shipping thousands of small messages per second.

This article is an overview of how to communicate with Web Workers without turning postMessage into a firehose. We will start with a basic Comlink setup, then look at transferables, SharedArrayBuffer, Atomics, double buffering, and a shared ring buffer for ordered events.

The rule of thumb I keep coming back to is simple:

Messages for “do this once.” Shared memory for “here is the latest state, keep reading.”

Raw postMessage works, but the API is intentionally low-level. You end up writing switch statements, inventing message types, and manually wiring request and response IDs. That is fine for a tiny demo. It gets noisy once you have a real API surface.

Comlink turns a worker into something that feels like a normal async object. You expose a class or object from the worker, wrap the other side, and call methods as if they lived in the same thread.

On the worker side:

// physics.worker.js
import * as Comlink from 'comlink';

const api = {
  async init(config) {
    // allocate simulation state, load WASM, etc.
  },

  async resize(width, height) {
    // rare control update
  },

  async registerBody(id) {
    // create something once
  },
};

Comlink.expose(api);

On the main thread:

import * as Comlink from 'comlink';

const worker = new Worker(new URL('./physics.worker.js', import.meta.url), {
  type: 'module',
});

const physics = Comlink.wrap(worker);

await physics.init({ gravity: -9.81 });
await physics.registerBody(1);

This is the control plane. Lifecycle calls, configuration, registering resources, connecting a network session: all of that belongs here. The cost of a Comlink call is still a postMessage under the hood (serialize, queue, wake the other thread, deserialize, maybe reply). That cost is acceptable when it happens a few times per second. It is not acceptable when it happens thousands of times per frame.

If your architecture only needs infrequent RPC, stop here. Seriously. Shared memory adds complexity, browser requirements, and debugging pain. Do not reach for it until postMessage is actually the problem.

Transferables: the middle ground

Before SharedArrayBuffer, there is a useful middle step: transferable objects.

When you postMessage a plain object, the browser structured-clones it. When you transfer an ArrayBuffer or an OffscreenCanvas, ownership moves to the other side instead. No deep copy of the bytes, but also no shared access afterward. The sender loses the object.

const canvas = document.querySelector('canvas');
const offscreen = canvas.transferControlToOffscreen();

worker.postMessage({ canvas: offscreen }, [offscreen]);

With Comlink, the same idea looks like this:

await renderer.init(
  Comlink.transfer(
    { canvas: offscreen, width, height },
    [offscreen],
  ),
);

Transferables are perfect for one-shot handoffs: give the renderer an OffscreenCanvas, ship a decoded model buffer once, move a large blob without cloning it. They are not a replacement for continuous updates. Once the buffer is gone from your side, you cannot keep writing into it every frame.

So the ladder looks like this:

  1. Comlink / postMessage for rare control
  2. Transferables for large one-time payloads
  3. Shared memory when both sides must keep reading and writing the same live data

SharedArrayBuffer: share bytes, not messages

A SharedArrayBuffer is exactly what the name suggests: a block of memory that multiple threads can see at the same time. You allocate it once, pass it to the worker during init (usually via transfer of the shared buffer handle), and both sides create typed-array views over the same bytes.

const sab = new SharedArrayBuffer(Float32Array.BYTES_PER_ELEMENT * 3);
const position = new Float32Array(sab);

// main thread writes
position[0] = x;
position[1] = y;
position[2] = z;

// worker reads the same slots on its own tick
const x = position[0];
const y = position[1];
const z = position[2];

After the initial setup message, the hot path no longer posts anything. The writer overwrites slots in place. The reader polls those slots on its own schedule (requestAnimationFrame, a fixed simulation tick, whatever you already run).

That is the whole idea. You invent a small binary layout, document the offsets, and treat the buffer as a shared blackboard.

A few practical notes before we go further:

  • SharedArrayBuffer requires cross-origin isolation. Your page needs Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp (or an equivalent credentialless setup). Without that, SharedArrayBuffer is simply unavailable.
  • There are no structured types inside the buffer. A Float32Array of length 3 is a position. An Int32Array slot is a flag. You own the meaning of every index.
  • Debugging gets harder. DevTools will not show you a nice message log for shared memory writes. Build small inspectors early if the system grows.

Atomics: the sync toolkit

Shared memory without synchronization is how you invent heisenbugs. One thread writes a position while another reads a half-updated vector. Or worse: one thread thinks a sequence of fields is consistent when it is not.

Atomics gives you the low-level tools for concurrent access on shared integer typed arrays (Int32Array, BigInt64Array, and friends):

  • Atomics.load / Atomics.store for single-slot reads and writes that will not tear in surprising ways on that slot
  • Atomics.compareExchange for lock-free flags and handshakes
  • Atomics.add / Atomics.sub for counters and reference counts
  • Atomics.wait / Atomics.notify when a sleeping worker should wake up after new data arrives

A tiny mailbox flag is often enough:

const flagSab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
const flags = new Int32Array(flagSab);

// writer
view[0] = value;
Atomics.store(flags, 0, 1); // mark ready
Atomics.notify(flags, 0);   // optional wake-up

// reader
if (Atomics.load(flags, 0) === 1) {
  const value = view[0];
  Atomics.store(flags, 0, 0);
}

If both sides already run a continuous loop (a renderer frame, a 60 Hz physics tick), you can skip wait / notify and just poll. That is common in games and other real-time UIs. Use notify when a worker would otherwise sit idle waiting for work.

Atomics alone are not a full protocol. They are the screws and hinges. The next two sections are the furniture you build with them.

Pattern: double buffering

When you only care about the latest value, a ring of events is overkill. You want the writer to publish a complete snapshot, and the reader to always see a coherent one.

Double buffering does exactly that. Keep two slots (A and B). The writer fills the inactive one, then flips an atomic index so readers switch to the new snapshot.

const STATE_FLOATS = 3; // x, y, z
const sab = new SharedArrayBuffer(
  Int32Array.BYTES_PER_ELEMENT + // active index
    Float32Array.BYTES_PER_ELEMENT * STATE_FLOATS * 2,
);

const meta = new Int32Array(sab, 0, 1);
const states = new Float32Array(sab, Int32Array.BYTES_PER_ELEMENT);

function publish(x, y, z) {
  const active = Atomics.load(meta, 0);
  const next = active ^ 1;
  const base = next * STATE_FLOATS;

  states[base] = x;
  states[base + 1] = y;
  states[base + 2] = z;

  Atomics.store(meta, 0, next);
}

function readLatest(out) {
  const active = Atomics.load(meta, 0);
  const base = active * STATE_FLOATS;
  out.x = states[base];
  out.y = states[base + 1];
  out.z = states[base + 2];
}

Why this works well:

  • Readers never observe a half-written snapshot if the writer finishes the inactive buffer before flipping the index.
  • Old values are simply overwritten. There is no queue growth under load.
  • It maps cleanly to “latest pose”, “latest meters”, “latest server tick”.

When one buffer is not enough fields

Sometimes your snapshot is several related fields that must stay consistent together, and allocating two full copies feels heavy. A lightweight alternative is a seqlock:

  1. Writer sets a sequence number to odd (“writing”).
  2. Writer updates the fields.
  3. Writer sets the sequence number to even (“consistent”).
  4. Reader loads the sequence, reads the fields, loads the sequence again. If it was odd, or if it changed, retry (or keep the previous good snapshot).
function publishTracked(x, y, z, qx, qy, qz, qw) {
  const begin = Atomics.load(seq, 0);
  Atomics.store(seq, 0, begin + 1); // odd: write in progress

  position[0] = x;
  position[1] = y;
  position[2] = z;
  orientation[0] = qx;
  orientation[1] = qy;
  orientation[2] = qz;
  orientation[3] = qw;

  Atomics.store(seq, 0, begin + 2); // even: consistent again
}

function readTracked(out) {
  for (let i = 0; i < 8; i++) {
    const s1 = Atomics.load(seq, 0);
    if (s1 & 1) continue;

    out.x = position[0];
    out.y = position[1];
    out.z = position[2];
    out.qx = orientation[0];
    out.qy = orientation[1];
    out.qz = orientation[2];
    out.qw = orientation[3];

    if (Atomics.load(seq, 0) === s1) return true;
  }
  return false; // contended: keep previous snapshot
}

Use double buffering when the snapshot is small and swapping is cheap. Reach for a seqlock when you need multi-field consistency without maintaining two full copies, and readers can tolerate an occasional retry.

Pattern: shared SPMC ring

Latest-state patterns drop intermediate values on purpose. That is wrong for input commands, network packets you must process in order, or any stream where missing an event changes behavior.

For that, use a ring buffer in shared memory: a fixed-capacity circular queue with atomic head and tail indexes.

A useful variant in multi-worker apps is SPMC: single producer, multiple consumers. One thread writes commands. Several workers each keep their own read head and independently drain the same stream.

A compact layout can look like this:

[0]          tail          (producer write position)
[1]          head0         (consumer 0 read position)
[2]          head1         (consumer 1 read position)
[3 ...]      payload slots

Sketch of the producer side:

function writeCommand(cmd) {
  const tail = Atomics.load(view, TAIL);
  const next = (tail + 1) % CAPACITY;

  // If we are about to lap a consumer, bump that consumer's head.
  for (let i = 0; i < CONSUMERS; i++) {
    const head = Atomics.load(view, HEAD0 + i);
    if (next === head) {
      Atomics.store(view, HEAD0 + i, (head + 1) % CAPACITY);
    }
  }

  const o = DATA + tail * STRIDE;
  pack(view, o, cmd); // your fixed binary layout
  Atomics.store(view, TAIL, next);
}

And a consumer:

function drain(consumerId, onCommand) {
  let head = Atomics.load(view, HEAD0 + consumerId);
  const tail = Atomics.load(view, TAIL);

  while (head !== tail) {
    const o = DATA + head * STRIDE;
    onCommand(unpack(view, o));
    head = (head + 1) % CAPACITY;
  }

  Atomics.store(view, HEAD0 + consumerId, head);
}

Why this pattern shows up so often:

  • The producer can run at 60 Hz while one consumer runs at 60 Hz and another at 30 Hz, without inventing a second queue.
  • Each consumer advances on its own. A slow consumer does not block a fast one.
  • Overflow policy is explicit. In the sketch above, a lagged consumer loses its oldest entries. You can also choose to drop the newest write, or grow capacity until that almost never happens.

Pack the payload tightly. Fixed strides beat variable-length messages in shared rings. If you need floats next to ints, overlay a Float32Array on the same buffer at the right byte offset, or reserve int slots and store bit patterns carefully. Keep the layout boring and documented.

Putting it together

A healthy worker architecture is usually hybrid, not purist.

NeedPrefer
Rare RPC / lifecycleComlink or plain postMessage
One large handoffTransferable (OffscreenCanvas, ArrayBuffer)
Latest values (pose, meters, flags)SharedArrayBuffer + double buffer or seqlock
Ordered events at high HzShared SPMC ring
Wake an idle workerAtomics.notify (or just poll if it already ticks)

In practice, that looks like:

  1. Spawn workers with Comlink.
  2. Transfer canvases, WASM modules, and big assets once.
  3. Allocate shared buffers during init and hand them to every participant.
  4. Keep posting control messages when the scene graph changes.
  5. Stop posting the live numbers. Write them into shared memory instead.

The mistake I see most often is using one tool for everything. Either every update becomes a Comlink call (and the message queue melts), or everything is forced into shared memory (and simple request/response flows become hand-rolled protocols). Split the traffic by frequency and semantics, and the system stays understandable.

What’s next

If you are starting from a classic worker setup, do not rewrite everything on day one. Profile first. Count how many messages you send per second, and what they contain. Move only the hot path into shared memory.

A good first experiment is a single shared Float32Array for a value you currently post every frame. Once that feels boring, add an atomic flag. Then try double buffering. Only introduce a ring when you actually need ordered events.

From there, the rabbit hole goes deeper: dirty bitsets, wait-free queues, version counters, and careful false-sharing avoidance. You probably do not need those on the first pass. You need a clear split between the control plane and the data plane.

Messages for “do this once.” Shared memory for “keep reading.”

Share article