Node Worker Threads: SharedArrayBuffer Protocols Without Data Races

Node Worker Threads: SharedArrayBuffer Protocols Without Data Races
12 views
worker_threads give you real parallelism for CPU-bound work. SharedArrayBuffer gives you a way to corrupt state at nanosecond speed if you invent a protocol casually. The senior pattern is boring: one writer owns each region, Atomics coordinate handoff, and ordinary JS objects never cross the boundary except as structured-clone messages for control plane events.
⚡ TL;DR: Use SAB for numeric payloads and ring buffers only; gate every multi-byte write with Atomics (or transfer ownership so only one thread touches a slice); keep request metadata on MessagePort; fuzz your protocol under node --test with thread sanitizers in CI where available. Pair with AI Test Generation: Property Tests That Catch Race Conditions for concurrency oracles.

Node Worker Threads: production guidance

If two threads can write the same index without a happens-before edge, you do not have a protocol — you have a race. Prefer single-producer/single-consumer rings.
typescript
// src/sab-ring.ts
const HEAD = 0; // Int32 index in control SAB
const TAIL = 1;

export function createRing(slots: number, slotBytes: number) {
 const control = new SharedArrayBuffer(8); // 2 * int32
 const data = new SharedArrayBuffer(slots * slotBytes);
 const ctrl = new Int32Array(control);
 Atomics.store(ctrl, HEAD, 0);
 Atomics.store(ctrl, TAIL, 0);
 return { control, data, slots, slotBytes };
}

export function tryPush(ctrl: Int32Array, data: Uint8Array, slots: number, slotBytes: number, payload: Uint8Array) {
 const head = Atomics.load(ctrl, HEAD);
 const tail = Atomics.load(ctrl, TAIL);
 if ((head + 1) % slots === tail) return false; // full
 data.set(payload, head * slotBytes);
 // ✅ Publish payload before advancing head
 Atomics.store(ctrl, HEAD, (head + 1) % slots);
 Atomics.notify(ctrl, HEAD, 1);
 return true;
}

export function tryPop(ctrl: Int32Array, data: Uint8Array, slots: number, slotBytes: number, out: Uint8Array) {
 const head = Atomics.load(ctrl, HEAD);
 const tail = Atomics.load(ctrl, TAIL);
 if (head === tail) return false;
 out.set(data.subarray(tail * slotBytes, tail * slotBytes + out.length));
 Atomics.store(ctrl, TAIL, (tail + 1) % slots);
 return true;
}
typescript
// ❌ Shared mutable object via workerData — not shared, not safe if you "fix" it with SAB tricks
workerData: { state: { counters: {} } }

Control plane on MessagePort, data plane on SAB

Config updates, cancel tokens, and error objects belong on the message channel. Bytes and floats belong on SAB.
typescript
// src/cpu-pool.ts
import { Worker } from "node:worker_threads";
import { createRing } from "./sab-ring.js";

export function startPool(n: number) {
 const ring = createRing(1024, 64);
 const workers = Array.from({ length: n }, () => {
 const w = new Worker(new URL("./cpu-worker.js", import.meta.url), {
 workerData: { control: ring.control, data: ring.data, slots: ring.slots, slotBytes: ring.slotBytes },
 });
 w.on("message", (msg) => {
 if (msg.type === "fatal") metrics.increment("pool.worker_fatal");
 });
 return w;
 });
 return { ring, workers };
}

Atomics wait without wedging the event loop

Atomics.wait blocks the worker thread — that is fine on workers, catastrophic on the main thread. On the main thread use Atomics.waitAsync (or message notifications) so you do not recreate the stalls covered in Node.js Event Loop Lag: Catch P99 Stalls.
typescript
// cpu-worker.ts
import { workerData, parentPort } from "node:worker_threads";

const ctrl = new Int32Array(workerData.control);
const data = new Uint8Array(workerData.data);

while (true) {
 // ✅ Block only inside the worker
 while (!tryConsume()) {
 Atomics.wait(ctrl, 0 /* HEAD */, Atomics.load(ctrl, 0));
 }
}

function tryConsume() {
 // pop + compute + optional MessagePort result for large JS values
 return false;
}

Testing races on purpose

Deterministic seeds + forced interleaving beat “it passed on my laptop.”
typescript
import { test } from "node:test";
import assert from "node:assert/strict";

test("ring never duplicates slots under contention", async () => {
 // spawn 1 producer + 4 consumers, push N items, assert set size === N
 assert.equal(1, 1); // replace with real harness
});

Closing checklist

✅ Dos
– ✅ Single-writer regions with Atomics publish order
– ✅ MessagePort for objects/errors/cancel
– ✅ Atomics.wait only on worker threads
– ✅ Fuzz fill/empty boundaries and wraparound
– ✅ Document endianness and slot layout in one ADR ❌ Don’ts
– ❌ Don’t share ordinary JS objects across threads
– ❌ Don’t use non-atomic multi-byte reads/writes on SAB
– ❌ Don’t Atomics.wait on the main thread
– ❌ Don’t grow SAB protocols without a version field
– ❌ Don’t ignore worker error / exit events

Related reading

Last updated on September 11, 2026 Related: Node.js Clustering and Worker Threads: Scale CPU-Bound Work Across All Cores; Node.js Streams: Process Gigabytes of Data Without Running Out of Memory; Structured Clone vs JSON: Faster Worker Message Passing in Node.

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.