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.
Step-by-Step Fix
-
Allocate the
SharedArrayBufferonce, before spawning workers. Size it for the full dataset up front — aSharedArrayBuffercannot be resized after construction unless you pass amaxByteLengthoption and callgrow(), and growing still reallocates within that ceiling. Expected output: one allocation event, visible as a single spike inprocess.memoryUsage().externalon the main thread. -
Post the buffer without listing it in
transferList. Callworker.postMessage({ shared: sab }). Because aSharedArrayBufferis shareable, not transferable, it must never appear in the secondtransferListargument — doing so throwsDataCloneError. Expected output: the message send returns immediately;sab.byteLengthon the sender is unchanged (it is never detached). -
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 === sharedBufferistruein every worker, and writing through one worker’s view is observable by another after a properAtomicssynchronization point. -
Replace every plain read/write on the shared region with
Atomicscalls. UseAtomics.store(view, index, value)to write,Atomics.load(view, index)to read, andAtomics.wait/Atomics.notifyfor producer-consumer signalling. Expected output: under concurrent load, no thread ever reads a value that is a torn mix of two writes. -
Verify with DevTools that no duplicate allocation occurred. Launch with
node --inspect app.js, openchrome://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. -
Release views before terminating workers. Set the local view variable to
nullin the worker’s shutdown path before calling or receivingworker.terminate(). Expected output:process.memoryUsage().externalon 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.
Related
- Worker Threads Memory Isolation — the parent guide on isolate boundaries and total process memory budgeting
- Transferring ArrayBuffers vs Copying Between Workers — when to detach-and-move a buffer instead of sharing it
- Node.js Server-Side Memory Management — the main section covering server heap growth, streams, and diagnosis
- Understanding the V8 Heap Layout and Memory Segments — where buffers sit relative to the generational heap spaces