Skip to content
Codeloom
JavaScript

SharedArrayBuffer and Atomics for Parallel JavaScript

Learn how to use SharedArrayBuffer and Atomics to share memory between threads, coordinate Web Workers, and build lock-free data structures in JavaScript.

·6 min read · By Codeloom
Advanced 12 min read

What you'll learn

  • How SharedArrayBuffer enables shared memory between threads
  • Using Atomics for thread-safe reads, writes, and synchronization
  • Building practical multi-threaded patterns with Web Workers

Prerequisites

  • JavaScript Web Workers basics
  • Understanding of TypedArrays

Most JavaScript runs in a single thread. Web Workers let you spin up additional threads, but normally each worker gets its own isolated memory — data must be copied via postMessage. SharedArrayBuffer changes this by letting multiple threads read and write the same block of memory, and Atomics provides the low-level synchronization primitives to do so safely.

SharedArrayBuffer basics

A SharedArrayBuffer is like an ArrayBuffer, but it can be sent to a Web Worker without transferring ownership. Both the main thread and the worker see the same underlying bytes.

// main.js
const sharedBuffer = new SharedArrayBuffer(1024); // 1 KB of shared memory
const view = new Int32Array(sharedBuffer);

view[0] = 42;

const worker = new Worker("worker.js");
worker.postMessage(sharedBuffer);
// worker.js
self.onmessage = (e) => {
  const view = new Int32Array(e.data);
  console.log(view[0]); // 42 -- same memory!
  view[0] = 100; // Main thread will see this change
};

Unlike a normal ArrayBuffer transfer, the main thread retains access to the buffer. Both sides share it.

The problem: data races

Shared memory introduces data races. If two threads read and write the same location without coordination, results become unpredictable.

// Thread A
view[0] = view[0] + 1;

// Thread B (simultaneously)
view[0] = view[0] + 1;

// Expected: view[0] incremented by 2
// Actual: might only increment by 1 (lost update)

The increment is not atomic — it involves a read, a computation, and a write. Another thread can interleave between those steps. This is where Atomics comes in.

Atomics: thread-safe operations

The Atomics object provides static methods that perform atomic (indivisible) operations on SharedArrayBuffer views.

Atomic read and write

const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);

// Atomic write -- guaranteed to complete before any other thread sees it
Atomics.store(view, 0, 42);

// Atomic read -- guaranteed to see the latest value
const value = Atomics.load(view, 0);
console.log(value); // 42

Atomic add and subtract

const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);

Atomics.store(view, 0, 10);

// Atomically add 5 and return the old value
const oldValue = Atomics.add(view, 0, 5);
console.log(oldValue); // 10
console.log(Atomics.load(view, 0)); // 15

// Atomically subtract 3
Atomics.sub(view, 0, 3);
console.log(Atomics.load(view, 0)); // 12

Compare and exchange

Atomics.compareExchange is the foundation for building higher-level synchronization. It atomically checks whether an index holds an expected value and, if so, replaces it.

const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);

Atomics.store(view, 0, 10);

// Only update if current value is 10
const old = Atomics.compareExchange(view, 0, 10, 20);
console.log(old); // 10 (was 10, so the swap happened)
console.log(Atomics.load(view, 0)); // 20

// Try again -- current value is now 20, not 10, so swap fails
const old2 = Atomics.compareExchange(view, 0, 10, 30);
console.log(old2); // 20 (was not 10, no swap)
console.log(Atomics.load(view, 0)); // 20

Wait and notify: thread coordination

Atomics.wait and Atomics.notify let threads block and wake each other, similar to condition variables in other languages.

// worker.js -- waiting thread
self.onmessage = (e) => {
  const view = new Int32Array(e.data);

  console.log("Worker waiting...");
  // Block until view[0] is no longer 0
  const result = Atomics.wait(view, 0, 0);
  console.log("Worker woke up:", result); // "ok"
  console.log("Value:", Atomics.load(view, 0));
};
// main.js -- notifying thread
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);

const worker = new Worker("worker.js");
worker.postMessage(sab);

setTimeout(() => {
  Atomics.store(view, 0, 1);
  Atomics.notify(view, 0, 1); // Wake one waiting thread
}, 2000);

Important: Atomics.wait cannot be called on the main thread in browsers (it would block the UI). Use Atomics.waitAsync instead, which returns a promise.

// Main thread -- non-blocking wait
const result = Atomics.waitAsync(view, 0, 0);
result.value.then(() => {
  console.log("Value changed:", Atomics.load(view, 0));
});

Building a simple spinlock

A spinlock is a basic mutual exclusion mechanism. While not ideal for performance, it demonstrates how Atomics enable synchronization primitives.

class SpinLock {
  constructor(sharedArray, index) {
    this.view = sharedArray;
    this.index = index;
  }

  lock() {
    // Keep trying until we successfully set 0 -> 1
    while (Atomics.compareExchange(this.view, this.index, 0, 1) !== 0) {
      // Spin -- in real code you might want to yield or back off
    }
  }

  unlock() {
    Atomics.store(this.view, this.index, 0);
  }
}

// Usage (in a worker)
const sab = new SharedArrayBuffer(8); // 4 bytes for lock, 4 for data
const lockView = new Int32Array(sab);
const lock = new SpinLock(lockView, 0);

lock.lock();
// Critical section -- only one thread at a time
Atomics.add(lockView, 1, 1);
lock.unlock();

Practical example: parallel sum

Here is a complete example that splits an array across workers and computes a sum in parallel.

// main.js
const WORKER_COUNT = 4;
const DATA_SIZE = 1_000_000;

// Shared buffer: data + one slot per worker for partial sums
const dataBuffer = new SharedArrayBuffer(DATA_SIZE * 4);
const resultBuffer = new SharedArrayBuffer(WORKER_COUNT * 4);
const data = new Int32Array(dataBuffer);
const results = new Int32Array(resultBuffer);

// Fill with test data
for (let i = 0; i < DATA_SIZE; i++) {
  data[i] = 1; // Sum should be 1,000,000
}

const chunkSize = Math.ceil(DATA_SIZE / WORKER_COUNT);
let completed = 0;

for (let i = 0; i < WORKER_COUNT; i++) {
  const worker = new Worker("sum-worker.js");
  worker.postMessage({
    dataBuffer,
    resultBuffer,
    workerIndex: i,
    start: i * chunkSize,
    end: Math.min((i + 1) * chunkSize, DATA_SIZE),
  });

  worker.onmessage = () => {
    completed++;
    if (completed === WORKER_COUNT) {
      let total = 0;
      for (let j = 0; j < WORKER_COUNT; j++) {
        total += Atomics.load(results, j);
      }
      console.log("Total sum:", total); // 1000000
    }
  };
}
// sum-worker.js
self.onmessage = (e) => {
  const { dataBuffer, resultBuffer, workerIndex, start, end } = e.data;
  const data = new Int32Array(dataBuffer);
  const results = new Int32Array(resultBuffer);

  let sum = 0;
  for (let i = start; i < end; i++) {
    sum += data[i];
  }

  Atomics.store(results, workerIndex, sum);
  self.postMessage("done");
};

Security requirements

Browsers disabled SharedArrayBuffer after the Spectre vulnerability. To re-enable it, your server must set two headers:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

You can check availability at runtime:

if (typeof SharedArrayBuffer !== "undefined") {
  console.log("SharedArrayBuffer is available");
} else {
  console.log("SharedArrayBuffer is not available -- check COOP/COEP headers");
}

Summary

SharedArrayBuffer and Atomics bring true shared-memory parallelism to JavaScript:

  • SharedArrayBuffer creates memory accessible from multiple threads without copying.
  • Atomics.load and Atomics.store ensure reads and writes are atomic.
  • Atomics.add, Atomics.sub, and Atomics.compareExchange provide lock-free data manipulation.
  • Atomics.wait and Atomics.notify coordinate threads via blocking and waking.
  • Security headers (COOP/COEP) are required in browsers.

These APIs are powerful but low-level. For most applications, the message-passing model with postMessage is simpler and less error-prone. Reach for shared memory when you need maximum throughput for CPU-intensive parallel computation.