The `SharedArrayBuffer` in TypeScript

The `SharedArrayBuffer` in TypeScript

This article explains the SharedArrayBuffer in TypeScript.

We will explain SharedArrayBuffer in TypeScript with practical examples.

YouTube Video

The SharedArrayBuffer in TypeScript

SharedArrayBuffer is a mechanism for sharing the same memory space between multiple threads, such as Web Workers. By combining it with Atomics, you can manage data races and perform low-latency shared memory operations.

Prerequisites and Notes

When using SharedArrayBuffer in the browser, COOP and COEP headers are required to meet security requirements known as cross-origin isolation. In Node.js, shared memory can be handled relatively easily using worker_threads.

Basic Concepts

SharedArrayBuffer is an object representing a fixed-length sequence of bytes, and you can read and write numbers via TypedArray and similar views. Simple read/write operations are not synchronized, so you use the Atomics API to ensure atomic operations and use wait and notify mechanisms for coordination.

Simple Counter (Browser version)

In this example, the main thread creates a SharedArrayBuffer and passes it to a Web Worker, with both incrementing a shared counter. This demonstrates the minimal pattern: atomic addition with Atomics.add and reading with Atomics.load.

main.ts (Browser side)

This example shows how the main thread creates a SharedArrayBuffer and shares it with a Worker for multi-threaded access.

 1// main.ts
 2// Create a 4-byte (Int32) shared buffer for one counter
 3const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 1);
 4const counter = new Int32Array(sab); // view over the buffer
 5
 6// Create worker and transfer the SharedArrayBuffer
 7const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
 8worker.postMessage({ sab });
 9
10// Increase counter in main thread every 200ms
11setInterval(() => {
12  const newVal = Atomics.add(counter, 0, 1) + 1; // Atomically increment
13  console.log('Main incremented ->', newVal);
14}, 200);
15
16// Listen for messages (optional)
17worker.onmessage = (e) => {
18  console.log('From worker:', e.data);
19};
  • In this code, the main thread uses Atomics.add to increment the value atomically. On the worker.ts side, the same SharedArrayBuffer can be accessed and manipulated.

worker.ts (Browser Worker)

This is an example where the worker receives the same shared buffer and periodically decrements or manipulates it.

 1// worker.ts
 2self.onmessage = (ev: MessageEvent) => {
 3  const { sab } = ev.data as { sab: SharedArrayBuffer };
 4  const counter = new Int32Array(sab);
 5
 6  // Every 350ms, atomically add 2 (demonstration)
 7  setInterval(() => {
 8    const newVal = Atomics.add(counter, 0, 2) + 2;
 9    // Optionally notify main thread (postMessage)
10    self.postMessage(`Worker added 2 -> ${newVal}`);
11  }, 350);
12};
  • The Worker also manipulates the same memory through Int32Array, and updates are made without race conditions thanks to Atomics.

Synchronization using wait/notify

By using Atomics.wait and Atomics.notify, you can suspend threads until certain conditions are met, enabling event-driven synchronization within worker threads. In browsers, the safest approach is to use Atomics.wait inside a Worker.

producer.ts (Browser Producer Worker)

The producer writes data and notifies the consumer using notify.

 1// producer.ts (worker)
 2self.onmessage = (ev: MessageEvent) => {
 3  const { sab } = ev.data as { sab: SharedArrayBuffer };
 4  const state = new Int32Array(sab); // state[0] will be 0=empty,1=filled
 5
 6  // produce every 500ms
 7  setInterval(() => {
 8    // produce: set state to 1 (filled)
 9    Atomics.store(state, 0, 1);
10    Atomics.notify(state, 0, 1); // wake one waiter
11    self.postMessage('produced');
12  }, 500);
13};

consumer.ts (Browser Consumer Worker)

The consumer waits with Atomics.wait and resumes processing upon notification from the producer.

 1// consumer.ts (worker)
 2self.onmessage = (ev: MessageEvent) => {
 3  const { sab } = ev.data as { sab: SharedArrayBuffer };
 4  const state = new Int32Array(sab);
 5
 6  // consumer loop
 7  (async function consumeLoop() {
 8    while (true) {
 9      // if state is 0 => wait until notified (value changes)
10      while (Atomics.load(state, 0) === 0) {
11        // Blocks this worker until notified or timeout
12        Atomics.wait(state, 0, 0);
13      }
14      // consume (reset to 0)
15      // (processing simulated)
16      // read data from another shared buffer in real scenarios
17      Atomics.store(state, 0, 0);
18      // continue loop
19      self.postMessage('consumed');
20    }
21  })();
22};
  • In this pattern, the producer notifies the consumer via Atomics.notify and the consumer waits efficiently using Atomics.wait. Atomics.wait cannot be called on the main thread in browsers. To prevent the UI from freezing, Atomics.wait is restricted to use only within Workers.

Practical Example with Node.js (worker_threads)

In a Node.js environment, you can handle SharedArrayBuffer functionality using worker_threads. Below is a typed TypeScript sample for Node.js.

main-node.ts

The main thread creates the buffer and passes it to the Worker.

 1// main-node.ts
 2import { Worker } from 'worker_threads';
 3import path from 'path';
 4
 5// Create SharedArrayBuffer for one 32-bit integer
 6const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
 7const counter = new Int32Array(sab);
 8
 9// Spawn worker and pass sab via workerData
10const worker = new Worker(path.resolve(__dirname, 'worker-node.js'), {
11  workerData: { sab }
12});
13
14// Log messages from worker
15worker.on('message', (msg) => console.log('Worker:', msg));
16
17// Increment in main thread periodically
18setInterval(() => {
19  const val = Atomics.add(counter, 0, 1) + 1;
20  console.log('Main increment ->', val);
21}, 300);

worker-node.ts

This example uses parentPort and workerData from worker_threads on the Worker side.

 1// worker-node.ts
 2import { parentPort, workerData } from 'worker_threads';
 3
 4const sab = workerData.sab as SharedArrayBuffer;
 5const counter = new Int32Array(sab);
 6
 7// Periodically add 5
 8setInterval(() => {
 9  const val = Atomics.add(counter, 0, 5) + 5;
10  parentPort?.postMessage(`Added 5 -> ${val}`);
11}, 700);
  • In Node.js, there are no COOP or COEP restrictions like in browsers, so shared memory can be easily handled just by using worker_threads. When using TypeScript, pay attention to whether you are building with CommonJS or ESM settings.

TypeScript Typing Points

SharedArrayBuffer and Atomics are included in the standard DOM and library type definitions, so you can use them directly in TypeScript. When exchanging messages with Workers, it is safer to define interfaces and specify types clearly.

1// Example: typed message
2type WorkerMessage = { type: 'init'; sab: SharedArrayBuffer } | { type: 'ping' };
  • Explicitly defining types makes postMessage and onmessage handling safer and enables type checking.

Practical Use Cases

SharedArrayBuffer is not always necessary, but it is highly effective in situations where the speed advantages of shared memory are required. Understanding the situations where it is effective leads to appropriate technology choices.

  • It is suitable for low-latency processing that requires high-speed shared buffers and can be used for real-time audio/video processing or game physics.
  • For simple data exchange or transferring large amounts of data, Transferable ArrayBuffer or postMessage may be easier to use than SharedArrayBuffer.

Constraints and Security

To use SharedArrayBuffer in the browser, cross-origin isolation is required: set COOP to same-origin-allow-popups and COEP to require-corp. SharedArrayBuffer will be disabled unless these requirements are met.

Performance and Debugging Tips

Atomic operations (Atomics) are fast, but frequent waiting and excessive synchronization can increase latency.

The following points should be checked to handle shared memory safely and efficiently.

  • Views like Int32Array should be handled with proper byte alignment.
  • Be clear about which indices of the same shared buffer are used by which processes, and maintain consistent conventions in your code.
  • If you treat a SharedArrayBuffer like a normal ArrayBuffer, data races will occur. Writing to the same index from multiple threads without using Atomics is dangerous.

Summary

By making proper use of SharedArrayBuffer and Atomics, you can achieve safe and fast shared memory operations even in TypeScript. Starting with simple synchronization patterns like counters or signals is easier to understand, and by thoroughly managing correct synchronization and indices, you can be effective even in low-latency scenarios.

You can follow along with the above article using Visual Studio Code on our YouTube channel. Please also check out the YouTube channel.

YouTube Video