Node Worker Threads: SharedArrayBuffer Protocols Without Data Races

Node Worker Threads: SharedArrayBuffer Protocols Without Data Races

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.

Ownership beats locking theater

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.

// 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;
}
// ❌ 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.

// 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.

// 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.”

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


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 Reply