Sharing Memory Between Node.js Worker Threads with SharedArrayBuffer and Atomics
Why postMessage gives each Node.js worker a private copy, when SharedArrayBuffer is worth it, and how Atomics prevents lost updates and enables cross-thread waiting.
This article is published in English.
Sources: The Day I Learned JavaScript Threads Don’t Actually Share Memory
Node.js worker threads are real operating-system threads, each with its own JavaScript engine instance. That makes it tempting to assume they share memory the way threads do in C or Java, and that assumption breaks the first time two workers are supposed to coordinate over the same data. This article shows what postMessage really does with your values, when true shared memory is justified, and how Atomics keeps concurrent updates correct.
If you are new to workers in general, start with offloading CPU-heavy work in Node.js with worker threads and pools; the focus here is narrower: what happens to memory once a worker exists.
postMessage sends a copy, not a reference
The experiment starts with the main thread spawning a worker from a separate file.
// main.js
const { Worker } = require("worker_threads");
const worker = new Worker("./worker.js");
It then sends an ordinary array to the worker and, a moment later, logs its own copy of that array.
const sharedLookingArray = [1, 2, 3];
worker.postMessage(sharedLookingArray);setTimeout(() => {
console.log("Main thread's array:", sharedLookingArray); // still [1, 2, 3]
}, 100);
On the other side, the worker receives the array, appends an element and logs the result.
// worker.js
const { parentPort } = require("worker_threads");
parentPort.on("message", (arr) => {
arr.push(4);
console.log("Worker's array:", arr); // [1, 2, 3, 4]
});
The worker prints [1, 2, 3, 4], while the main thread still prints [1, 2, 3] no matter how long it waits. The reason is that postMessage runs the value through the structured clone algorithm, the same mechanism exposed globally as structuredClone. The data is serialized on the sending side and rebuilt as an independent object on the receiving side, so after the call there are two unrelated arrays, one per thread.
This is a deliberate and safe default. Two threads cannot race on memory they were never both given. The cost is the copy itself, which grows with the size of the payload and is paid on every message. (For large binary data, Node can also move an ArrayBuffer to the other thread as a transferable, which avoids the copy but leaves the sender unable to use it; that is a hand-off, not sharing.)
When shared memory is the right tool
Copying becomes the wrong trade-off when threads must coordinate over the same data at high frequency, so frequently that shipping it back and forth would cost more than the work being parallelized. For that case JavaScript offers SharedArrayBuffer: a raw block of memory that several threads reference directly, with no copies involved.
The main thread again loads the worker module:
// main.js
const { Worker } = require("worker_threads");
Then it allocates a four-byte shared buffer, enough for one 32-bit integer, wraps it in an Int32Array view and passes the buffer to the worker through workerData.
const sharedBuffer = new SharedArrayBuffer(4); // 4 bytes, one 32-bit integer
const sharedCounter = new Int32Array(sharedBuffer);const worker = new Worker("./worker.js", { workerData: { sharedBuffer } });
The worker builds its own Int32Array view over the very same buffer:
// worker.js
const { workerData } = require("worker_threads");
const sharedCounter = new Int32Array(workerData.sharedBuffer);
From this point on, a write through either view is visible through the other, because both point at the same bytes.
sharedCounter[0] += 1; // this thread and the main thread are now touching the same memory
This is a real break from how JavaScript concurrency usually behaves. Code in a single realm runs on one thread, so the familiar async races, where two async functions interleave in a surprising order, are still one thread taking turns at well-defined points. With a SharedArrayBuffer, two threads can read and write the same memory at literally the same moment.
The lost update that is now possible
Consider the obvious way to increment the shared counter:
// naive, and genuinely unsafe with real shared memory
sharedCounter[0] = sharedCounter[0] + 1;
It looks like one operation, but it is three: read the current value, add one, write the result back. If another thread reads the counter between this thread's read and its write, both compute the same new value and one increment disappears. Nothing throws and nothing is logged; the final number is simply too low. Because the outcome depends on the exact timing of two OS threads, the bug may show up once in a thousand runs and vanish when you add logging.
Atomics: indivisible operations on shared memory
The Atomics object provides operations on shared integer arrays that execute as a single unit. No other thread can observe or interfere with an atomic operation halfway through.
Atomics.add(sharedCounter, 0, 1); // read, increment, and write, as one indivisible step
If every thread increments with Atomics.add instead of the read-modify-write line, no update can be lost, because the engine guarantees that other atomic operations on that element cannot interleave inside this one. The guarantee holds only if all writers use atomic operations; a single thread still doing the naive increment reintroduces the race. Everything more elaborate you might build on shared memory, whether counters, locks or queues, is ultimately assembled from operations like this one, which are atomic at the hardware level rather than "usually fine".
Atomics.wait and Atomics.notify: blocking across threads
Atomics also offers something JavaScript otherwise lacks: a way for one thread to stop executing entirely until another thread wakes it. In the worker, Atomics.wait blocks for as long as the counter still holds 0:
// worker.js — waits for main thread to signal it's time to proceed
Atomics.wait(sharedCounter, 0, 0); // blocks this thread while sharedCounter[0] === 0
console.log("Signaled to continue, value is now:", sharedCounter[0]);
The main thread stores a new value and then wakes one waiting thread:
// main.js — does the signaling
Atomics.store(sharedCounter, 0, 1);
Atomics.notify(sharedCounter, 0, 1); // wakes up one waiting thread
Storing before notifying matters. Atomics.wait checks the value first and returns immediately if it no longer equals the expected one, so a signal sent before the worker starts waiting is not lost.
This is a true blocking call, which is unusual in JavaScript, where asynchronous work normally flows through event-loop callbacks instead. Blocking the thread that runs the event loop would freeze everything else that thread handles, which is why browsers do not allow Atomics.wait on the main thread and why, in general, it belongs in worker threads. (Browsers also require the page to be cross-origin isolated before SharedArrayBuffer is available at all; Node has no such restriction.)
A realistic use: live progress across a worker pool
Here is a case where the added complexity pays off. Several CPU-bound workers each process a chunk of a large dataset, and the main thread wants a live progress count without every worker posting a message per item. The main thread creates one shared counter and hands its buffer to each worker along with its chunk:
// main.js
const progress = new Int32Array(new SharedArrayBuffer(4));
const workers = chunks.map((chunk) =>
new Worker("./process-chunk.js", { workerData: { chunk, progressBuffer: progress.buffer } })
);
It then samples the counter every half second with Atomics.load, which guarantees it reads a complete, current value:
setInterval(() => {
console.log(`Processed ${Atomics.load(progress, 0)} items so far`);
}, 500);
Each worker wraps the shared buffer in its own view:
// process-chunk.js
const { workerData } = require("worker_threads");
const progress = new Int32Array(workerData.progressBuffer);
As it processes items, it bumps the shared counter atomically:
for (const item of workerData.chunk) {
processItem(item);
Atomics.add(progress, 0, 1); // every worker safely increments the same shared counter
}
All workers write to the same four bytes directly, there is no per-item message overhead, and the main thread can read an accurate total at any moment without asking anyone to report in. This shape of problem, frequent and tight coordination between truly parallel CPU work, is what justifies shared memory over copying.
Most code should not reach for this
Typical backend Node code rarely needs any of it. Most concurrency in an API server is I/O-bound, which the single-threaded event loop already handles well. For the CPU-bound work that does come up, such as image processing, data transformation or cryptography, worker threads with plain postMessage copying are usually enough.
SharedArrayBuffer and Atomics are for a narrower situation: high-frequency coordination between parallel threads, where copying on every exchange would itself become the bottleneck. Adopting shared memory before you have measured that problem does not make code faster; it only brings in hardware-level races that the copying model was designed to rule out.
Key takeaways
postMessagestructured-clones data, so each thread works on its own copy and mutations never cross over.SharedArrayBuffergives several threads direct access to the same bytes through typed-array views.- A plain
x = x + 1on shared memory can silently lose updates; useAtomics.addand friends for every write. Atomics.waittruly blocks a thread and belongs in workers; pair it withAtomics.storeandAtomics.notify.- Reach for shared memory only after measuring that message copying is the bottleneck.