Transferable Objects solved one big Web Worker problem: moving large buffers between threads in O(1) time without copying. But there's a limit to what they can do. Once you transfer a buffer, it's gone from the sender — ownership is fully handed over.
What if you need two workers to share the same data at the same time? What if your audio worklet needs to read from a ring buffer that the main thread is constantly writing to? What if three workers are processing different chunks of a large dataset and need to coordinate progress?
Transferable Objects can't do this. SharedArrayBuffer can.
This article is the third piece of the browser threading puzzle. First came postMessage for communication, then Transferable Objects for zero-copy transfer, and now SharedArrayBuffer + Atomics for shared mutable state.
The Problem: You Can't Share with postMessage
With Transferable Objects, ownership transfers. The buffer detaches in the sender:
const buffer = new ArrayBuffer(1024);
worker.postMessage({ buffer }, [buffer]);
// buffer.byteLength === 0 now — it's gone
// You cannot read from it OR write to itThis works great when a worker processes data independently. But consider a shared ring buffer for audio — the main thread writes audio samples continuously while an audio worklet reads them. Neither can "own" the buffer exclusively. They both need it, at the same time, forever.
This is where SharedArrayBuffer comes in.
What Is SharedArrayBuffer?
SharedArrayBuffer is a fixed-size raw binary buffer that lives in a region of memory accessible to all threads — main thread and workers alike. There's no copying, no transfer, no ownership. Everyone has a reference to the same bytes.
// main.js
const sab = new SharedArrayBuffer(4); // 4 bytes of shared memory
const view = new Int32Array(sab);
view[0] = 42;
// Send to worker — no transfer list needed
// The worker gets a reference to the SAME memory
worker.postMessage({ sab });// worker.js
self.onmessage = (event) => {
const view = new Int32Array(event.data.sab);
console.log(view[0]); // 42 — reading the same bytes
view[0] = 100; // Writing directly to shared memory
};
// Back on main thread, view[0] is now 100Notice there's no second argument to postMessage. SharedArrayBuffer is not transferred — it's shared by reference. Both the main thread and the worker hold a live view into the same memory.
This is powerful. It's also dangerous.
Why You Need Atomics: The Race Condition Problem
When two threads read and write the same memory concurrently without coordination, you get race conditions. The results become non-deterministic — dependent on the exact order operations happen to execute.
Here's a classic example. Two workers each increment a counter 1000 times:
// BAD — race condition
const sab = new SharedArrayBuffer(4);
const counter = new Int32Array(sab);
// Both workers do this 1000 times:
counter[0] = counter[0] + 1; // READ, then ADD, then WRITEThe problem is that counter[0] + 1 is not a single atomic operation. It's three: read the value, add 1, write the result. If both workers read the same value (say, 500) before either writes, they both write 501 — and you've lost an increment. After 2000 increments across two workers, you might end up with 1247 instead of 2000.
Atomics fixes this by providing operations that are guaranteed to be indivisible — no thread can interrupt them halfway through.
Atomics: Atomic Read-Modify-Write
The Atomics object provides methods that operate on SharedArrayBuffer views atomically:
const sab = new SharedArrayBuffer(4);
const counter = new Int32Array(sab);
// Atomics.add returns the OLD value, then adds in one indivisible operation
const prev = Atomics.add(counter, 0, 1); // index 0, add 1
console.log(prev); // the value before adding
console.log(counter[0]); // the value after addingNow two workers can both call Atomics.add(counter, 0, 1) 1000 times each, and the result will always be exactly 2000. The hardware guarantees this — the CPU's compare-and-swap instruction makes it impossible for the read-modify-write to be interrupted.
Other useful atomic operations:
Atomics.store(view, index, value); // write atomically
Atomics.load(view, index); // read atomically
Atomics.sub(view, index, delta); // subtract
Atomics.and(view, index, mask); // bitwise AND
Atomics.or(view, index, mask); // bitwise OR
Atomics.compareExchange(view, index, expectedValue, replacementValue);
// only writes if current === expected — classic CAS operationAtomics.wait() and Atomics.notify(): Thread Synchronisation
Sometimes a worker needs to wait until another thread signals it. Atomics.wait() blocks a worker thread until a specific condition is met. Atomics.notify() wakes blocked threads.
This is the Web equivalent of a mutex or condition variable.
// worker.js — consumer
self.onmessage = (event) => {
const { sab } = event.data;
const flag = new Int32Array(sab);
// Wait until flag[0] becomes 1 (with 5-second timeout)
const result = Atomics.wait(flag, 0, 0, 5000);
// result is "ok" (notified), "timed-out", or "not-equal"
if (result === 'ok') {
console.log('Got signal — proceeding');
}
};// main.js — producer
const sab = new SharedArrayBuffer(4);
const flag = new Int32Array(sab);
worker.postMessage({ sab });
// Do some work, then signal the worker
setTimeout(() => {
Atomics.store(flag, 0, 1); // set the flag
Atomics.notify(flag, 0, 1); // wake up 1 waiting thread
}, 2000);One critical constraint: Atomics.wait() cannot be called on the main thread — it would block the UI entirely. Use it only in workers. On the main thread, use Atomics.waitAsync() which returns a Promise instead of blocking.
// main.js — non-blocking wait
const result = await Atomics.waitAsync(flag, 0, 0).value;
// resolves when flag[0] changes from 0, or on timeoutAtomics.waitAsync() is supported in all modern browsers (Chrome 87+, Firefox 100+, Safari 16.4+), but if you need to support older environments, check compatibility first.
Real Use Cases
Shared Ring Buffer for Audio
Audio worklets process small chunks of samples (128 frames at a time) at a precise, uninterruptible timing. If you try to send audio data via postMessage, you'll get glitches — the message queue doesn't guarantee low-latency delivery.
A shared ring buffer is the standard solution:
// main.js — allocate shared ring buffer
const BUFFER_SIZE = 4096; // must be power of 2
const sab = new SharedArrayBuffer(
Int32Array.BYTES_PER_ELEMENT * 2 + // read + write head
Float32Array.BYTES_PER_ELEMENT * BUFFER_SIZE
);
const control = new Int32Array(sab, 0, 2); // [readHead, writeHead]
const samples = new Float32Array(sab, 8); // audio samples
// Write to shared buffer — audio worklet reads from it
function pushSamples(newSamples) {
const writeHead = Atomics.load(control, 1);
for (let i = 0; i < newSamples.length; i++) {
samples[(writeHead + i) % BUFFER_SIZE] = newSamples[i];
}
Atomics.store(control, 1, (writeHead + newSamples.length) % BUFFER_SIZE);
}The audio worklet reads samples directly from the SharedArrayBuffer without any postMessage latency. This is how the Web Audio API's AudioWorkletProcessor is typically fed in high-performance audio apps.
Shared Frame Buffer for Canvas Workers
Offscreen canvas workers can render to a SharedArrayBuffer frame buffer, while the main thread composites or displays the results — with no copy between threads:
// main.js
const WIDTH = 1920;
const HEIGHT = 1080;
const frameBuffer = new SharedArrayBuffer(WIDTH * HEIGHT * 4); // RGBA
renderWorker.postMessage({ frameBuffer, WIDTH, HEIGHT });
// Main thread reads from frameBuffer whenever it needs to display
function displayFrame() {
const pixels = new Uint8ClampedArray(frameBuffer);
const imageData = new ImageData(pixels, WIDTH, HEIGHT);
ctx.putImageData(imageData, 0, 0);
requestAnimationFrame(displayFrame);
}The worker renders directly into frameBuffer — no copy needed when the main thread displays it.
Coordinating Multiple Workers on a Large Dataset
If you split a large computation across four workers, you can use a SharedArrayBuffer counter for coordination — each worker atomically claims the next chunk of work:
// Shared work counter
const sab = new SharedArrayBuffer(4);
const workIndex = new Int32Array(sab);
const TOTAL_CHUNKS = 1000;
// Each worker runs this loop
function workerLoop() {
while (true) {
// Atomically claim the next chunk
const myChunk = Atomics.add(workIndex, 0, 1);
if (myChunk >= TOTAL_CHUNKS) break;
processChunk(myChunk);
}
}No message-passing overhead. No central dispatcher. Each worker grabs work as fast as it can process.
The Security Story: Why SAB Was Disabled in 2018
In January 2018, SharedArrayBuffer was disabled in all browsers overnight in response to Spectre — a hardware vulnerability that let attackers read arbitrary memory using high-resolution timers.
The problem: SharedArrayBuffer + Atomics.wait() created an extremely precise timer, which could measure cache timing differences and infer memory contents from other processes. This was a genuine security risk.
Browsers re-enabled SharedArrayBuffer in 2020 with a new security model — cross-origin isolation. Two HTTP headers are required:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpThese headers put your page in a "cross-origin isolated" context — which restricts what can be embedded (no cross-origin iframes without CORS, no cross-origin popups), but in return grants access to SharedArrayBuffer and high-resolution timers.
You can verify isolation is active:
console.log(crossOriginIsolated); // true if SAB is availableSetting COOP/COEP on Netlify
Add these headers to your netlify.toml:
[[headers]]
for = "/*"
[headers.values]
Cross-Origin-Opener-Policy = "same-origin"
Cross-Origin-Embedder-Policy = "require-corp"Deploy and verify with crossOriginIsolated === true in the console. Note: if your site embeds third-party iframes (ads, YouTube, Stripe), they need to send Cross-Origin-Resource-Policy: cross-origin or you'll need to relax COEP to credentialless instead.
SharedArrayBuffer vs Transferable Objects vs postMessage
postMessage (clone) | Transferable Objects | SharedArrayBuffer | |
|---|---|---|---|
| Copy cost | Full copy | O(1) — no copy | No copy, no transfer |
| After send | Sender still has data | Sender loses data | Both keep access |
| Concurrent access | No | No | Yes |
| Thread safety | N/A | N/A | Requires Atomics |
| COOP/COEP required | No | No | Yes |
| Best for | Small data, messages | Large one-shot transfer | Ongoing shared state |
The rule of thumb:
- Default to
postMessagefor messages and small data - Use Transferable Objects when you're sending large buffers you don't need back
- Use
SharedArrayBufferwhen two or more threads need live, concurrent access to the same data
For cross-tab communication instead of cross-worker, see BroadcastChannel.
Browser Support and Gotchas
Browser support: All major browsers support SharedArrayBuffer when cross-origin isolated (Chrome 92+, Firefox 79+, Safari 15.2+). It is not available in non-isolated contexts.
SharedArrayBuffer cannot be structured-cloned across origins. You can share it within the same origin, but you can't send it to a cross-origin iframe or worker — the buffer reference simply won't transfer.
Typed array views are not transferable. You can only send SharedArrayBuffer itself via postMessage — not a Float32Array view of it. But since it's shared (not transferred), you just send the underlying SAB and reconstruct the view on the other end.
Atomics.wait() blocks the thread. Never call it on the main thread. In non-worker code, use Atomics.waitAsync() for async waiting.
SAB size is fixed at creation. Unlike a regular ArrayBuffer, you cannot resize a SharedArrayBuffer. Plan your buffer sizes upfront.
Byte alignment matters when mixing TypedArrays. If you use different typed array views on the same SharedArrayBuffer (e.g., an Int32Array for control bytes and a Float32Array for data), each view must start at a byte offset that's a multiple of its element size. Float32Array requires 4-byte alignment, Float64Array requires 8-byte. The audio ring buffer example above handles this correctly with the BYTES_PER_ELEMENT * 2 offset — misaligning will throw a RangeError.
Debugging shared memory is hard. Since memory changes "live" across threads, console.log(view[0]) might show a value that's already been modified by another thread by the time you read it in DevTools. If you're chasing a bug, use Atomics.load() for reads (guarantees you see the latest committed value) and consider temporarily reducing to a single worker to isolate the issue.
Summary
SharedArrayBufferis a fixed-size binary buffer accessible from any thread — no copy, no transfer, both sides hold a live reference- Without
Atomics, concurrent reads and writes cause race conditions — results become unpredictable Atomicsprovides indivisible read-modify-write operations (add,store,load,compareExchange) and thread synchronisation (wait/notify)- Real use cases: audio ring buffers, shared frame buffers, parallel work queues
- SAB requires cross-origin isolation — add
COOP: same-originandCOEP: require-corpheaders (on Netlify:netlify.toml) - Use SAB when you need concurrent shared access; use Transferable Objects for one-shot large transfers; use
postMessagefor everything else
Related Articles
- JavaScript Transferable Objects — the previous article in this series
- BroadcastChannel API — cross-tab communication without a shared buffer