Sharing Memory Between Worker Threads with SharedArrayBuffer

If every worker_threads job you spawn seems to multiply your process’s external memory by the number of workers, the cause is almost always that a large payload is being structured-cloned per worker instead of shared once — a problem this page fixes as part of worker threads memory isolation, itself a section of Node.js Server-Side Memory Management.

Symptom Root Cause Immediate Action
external memory scales linearly with worker count Same buffer structured-cloned into each worker Allocate one SharedArrayBuffer, post it by reference
Workers read stale or torn values from a shared buffer Plain array indexing with no memory ordering Read/write through Atomics.load / Atomics.store
Worker pool RSS never drops after jobs finish A view over the shared buffer is never released Null out typed-array views before worker.terminate()
TypeError when passing a SharedArrayBuffer Buffer listed in the transferList argument Omit it from transferList — it is shared, not transferred

Root Cause

Every Worker in worker threads memory isolation runs in its own V8 Isolate, and by default nothing on one isolate’s heap is visible to another. When you postMessage a plain object or a regular ArrayBuffer without transferring it, V8 runs the structured clone algorithm: it walks the value, serializes it, and allocates a brand-new copy in the receiving isolate. Do this from a pool of eight workers and an 80 MB dataset becomes roughly 720 MB of resident memory — one full copy per worker plus the original.

A SharedArrayBuffer sidesteps cloning entirely. Its backing store is allocated once, in native memory outside any single isolate’s generational heap, and every isolate that receives a reference to it maps the same pages rather than copying them. This is why it fits the pattern described for the V8 heap layout: a SharedArrayBuffer’s bytes never live in New Space or Old Space at all, so they are invisible to Scavenge and to mark-sweep-compact. What each isolate holds locally is a lightweight SharedArrayBuffer object and any typed-array views over it — a handful of bytes each — while the multi-megabyte payload sits once in shared native memory, reference-counted across every isolate that has touched it.

That reference-counting detail is the trap. Because the store is kept alive by any isolate still holding a reference, a worker that receives a SharedArrayBuffer, wraps it in a view, and is later killed with worker.terminate() without ever releasing that view can leave the allocation pinned until the isolate’s own teardown runs — and teardown is asynchronous. Concurrent access introduces a second hazard: because multiple threads can read and write the same bytes simultaneously, plain indexed reads and writes race. Atomics exists specifically to give you ordered, torn-free reads, writes, and blocking waits over that shared memory.

SharedArrayBuffer: One Backing Store, Many Views, Atomics-Coordinated Access Top row shows three worker isolates each receiving a full structured-clone copy of a buffer, tripling memory. Bottom row shows the same three worker isolates each holding only a lightweight typed-array view over one shared native backing store, with an Atomics coordination layer between the views to prevent race conditions. Structured clone (per worker copy) Worker A 80 MB copy Worker B 80 MB copy Worker C 80 MB copy 240 MB total resident SharedArrayBuffer (one backing store) Worker A Int32Array view Worker B Int32Array view Worker C Int32Array view Atomics coordination layer load / store / wait / notify — no torn reads 80 MB — allocated once, never duplicated

Step-by-Step Fix

  1. Allocate the SharedArrayBuffer once, before spawning workers. Size it for the full dataset up front — a SharedArrayBuffer cannot be resized after construction unless you pass a maxByteLength option and call grow(), and growing still reallocates within that ceiling. Expected output: one allocation event, visible as a single spike in process.memoryUsage().external on the main thread.

  2. Post the buffer without listing it in transferList. Call worker.postMessage({ shared: sab }). Because a SharedArrayBuffer is shareable, not transferable, it must never appear in the second transferList argument — doing so throws DataCloneError. Expected output: the message send returns immediately; sab.byteLength on the sender is unchanged (it is never detached).

  3. Wrap the shared buffer in a typed-array view inside each worker. Use new Int32Array(sharedBuffer) or a matching numeric view for the data layout you need. Expected output: view.buffer === sharedBuffer is true in every worker, and writing through one worker’s view is observable by another after a proper Atomics synchronization point.

  4. Replace every plain read/write on the shared region with Atomics calls. Use Atomics.store(view, index, value) to write, Atomics.load(view, index) to read, and Atomics.wait / Atomics.notify for producer-consumer signalling. Expected output: under concurrent load, no thread ever reads a value that is a torn mix of two writes.

  5. Verify with DevTools that no duplicate allocation occurred. Launch with node --inspect app.js, open chrome://inspect, and take a heap snapshot on the main thread and each worker target via DevTools → Memory → Heap Snapshot. Filter by “SharedArrayBuffer” in the Constructor view. Expected output: each isolate shows a small wrapper object referencing the backing store; the multi-megabyte allocation itself is attributed once, not once per isolate.

  6. Release views before terminating workers. Set the local view variable to null in the worker’s shutdown path before calling or receiving worker.terminate(). Expected output: process.memoryUsage().external on the main thread drops back toward baseline once all workers holding the shared store have exited and their isolates have been torn down.

Command & Code Reference

Allocating one shared backing store on the main thread and posting it by reference to a pool of workers.

const { Worker } = require('node:worker_threads');

// One allocation for the whole dataset, not one per worker
const sab = new SharedArrayBuffer(80 * 1024 * 1024); // 80 MB

// Main thread keeps its own view for writing initial data
const mainView = new Float64Array(sab);
mainView[0] = 1.5; // seed value, visible to every worker

for (let i = 0; i < 4; i++) {
  const worker = new Worker('./reader.js');
  // NOT in transferList: sharing, never detaches or copies
  worker.postMessage({ shared: sab });
}

Coordinating concurrent access inside a worker so reads never observe a torn write.

// reader.js — runs inside each worker
const { parentPort } = require('node:worker_threads');

parentPort.on('message', ({ shared }) => {
  // View wraps the SAME backing store, no copy made here
  const view = new Int32Array(shared);

  // Block this worker until index 0 changes from 0
  Atomics.wait(view, 0, 0);

  // Ordered read guarantees no torn value from a concurrent write
  const value = Atomics.load(view, 0);
  console.log('worker read coordinated value:', value);

  view = null; // drop the reference before this worker exits
});

Measuring memory to prove the store is not duplicated across isolates.

const v8 = require('node:v8');

// external includes SharedArrayBuffer/ArrayBuffer backing stores
const before = process.memoryUsage().external;

const sab = new SharedArrayBuffer(50 * 1024 * 1024); // 50 MB

const after = process.memoryUsage().external;
// Expect ~50 MB growth here, and NO further growth per
// worker that later receives a reference to the same sab
const deltaMb = (after - before) / 1024 / 1024;
console.log('external delta (MB):', deltaMb);

Verification & Regression Prevention

Track three numbers on every deploy touching worker code: process.memoryUsage().external on the main thread immediately after allocating the shared buffer (should equal the buffer size, ±5%); external again after every worker has received the reference (should show zero additional growth, since the store is shared, not cloned); and external after all workers holding views have terminated (should return within 10% of pre-allocation baseline within one GC cycle). Add a CI check that greps worker-facing postMessage call sites for a SharedArrayBuffer argument accidentally included in a transferList array — that combination throws at runtime and is easy to catch statically with a simple regex-based lint rule before it reaches production. In production, alert if external grows monotonically across N consecutive worker-pool recycles without returning to baseline; that pattern indicates a leaked view is keeping the shared store alive past its intended lifetime.

Frequently Asked Questions

Does SharedArrayBuffer get garbage collected while a worker still holds a view over it?

No. V8 keeps the backing store alive as long as any isolate holds a live reference to the SharedArrayBuffer or a typed-array view over it. The store is only freed once every thread — main and worker — has dropped its reference and each isolate’s GC has run. A worker that never nulls out its view, or that is terminated abruptly without releasing it, can keep the shared allocation resident indefinitely, which is why step 6 above matters as much as the allocation itself.

Why did my SharedArrayBuffer transfer throw an error?

A SharedArrayBuffer cannot be placed in the transferList argument of postMessage — that argument only accepts transferable objects such as a regular ArrayBuffer, which detaches from the sender on transfer (the distinction is covered fully in transferring vs copying ArrayBuffers). A SharedArrayBuffer is passed by reference inside the message body itself; omit it from transferList and every recipient maps the same backing store without copying or detaching anything.

Is Atomics.wait safe to call on the main thread?

No. Atomics.wait blocks the calling thread’s event loop entirely, and Node.js throws a TypeError if you call it on the main thread specifically to prevent freezing the process. Use it only inside a worker thread. On the main thread, poll with Atomics.waitAsync or use a message-based signal instead, so the event loop stays responsive while still coordinating with workers over the shared buffer.