For a 200 MB image buffer, postMessage's copy is the whole cost. SharedArrayBuffer is the way out: one allocation every thread maps into its own address space, with no serialization at either end. Pass it in workerData and both sides see the same bytes — with every hazard that implies.
import { Worker, isMainThread, workerData } from 'node:worker_threads';
const ROUNDS = 200_000;
if (isMainThread) {
const c = new Int32Array(new SharedArrayBuffer(8)); // [0] naive, [1] atomic
const ws = Array.from({ length: 8 }, () =>
new Worker(new URL(import.meta.url), { workerData: c }));
await Promise.all(ws.map((w) => new Promise((r) => w.on('exit', r))));
const expected = 8 * ROUNDS; console.log(`expected : ${expected}`);
console.log(`c[0]++ : ${c[0]} (lost ${expected - c[0]} updates)`);
console.log(`Atomics.add : ${Atomics.load(c, 1)}`);
} else {
for (let i = 0; i < ROUNDS; i++) { workerData[0]++; Atomics.add(workerData, 1, 1); }
}expected : 1600000 c[0]++ : 446275 (lost 1153725 updates) Atomics.add : 1600000
Seventy-two percent of the plain increments vanished. c[0]++ is three machine operations — load, add, store — and eight threads interleave them freely, so a thread stores a value computed from memory another thread has already moved past. Atomics.add does the same update as one uninterruptible instruction; use Atomics for every read and write of shared state.
Atomics.wait(arr, i, v) blocks until another thread calls Atomics.notify(arr, i) — a condition variable for parking an idle worker, though it must never run on the main thread. To hand a buffer over instead of sharing it, port.postMessage(buf, [buf.buffer]) transfers it in constant time and detaches your reference.