
Security News
Node.js Considers Public Workflow for Security Reports Amid AI-Driven Surge
Node.js is debating whether AI-driven security report volume warrants moving more vulnerability reports into public workflows.
@nxtedition/shared
Advanced tools
A high-performance, thread-safe ring buffer for inter-thread communication in Node.js using `SharedArrayBuffer`.
A high-performance, thread-safe ring buffer for inter-thread communication in Node.js using SharedArrayBuffer.
Passing data between worker threads in Node.js typically involves structured cloning or transferring ArrayBuffer ownership. Structured cloning copies every byte — fine for occasional messages, but a bottleneck when streaming megabytes per second between threads. Transferable objects avoid the copy, but each transfer still requires allocating a new ArrayBuffer, serializing the transfer list, and coordinating ownership between threads — overhead that adds up quickly in high-throughput scenarios.
This ring buffer avoids these problems. A single SharedArrayBuffer is mapped into both threads. The writer appends messages by advancing a write pointer; the reader consumes them by advancing a read pointer. No copies, no ownership transfers, no cloning overhead. Because messages are stored inline in a contiguous buffer, data lives right where the protocol is rather than scattered across separately allocated ArrayBuffers — keeping access patterns cache-friendly. The pointers are coordinated with Atomics operations, and cache-line-aligned to prevent false sharing between CPU cores.
Reads are zero-copy: the reader callback receives a DataView directly into the shared buffer, so parsing can happen in-place without allocating intermediate buffers. Writes are batched — the write pointer is only published to the reader after a high-water mark is reached or the current event loop tick ends, drastically reducing the frequency of expensive atomic stores.
All messages are aligned on 4-byte boundaries. Message length headers are read and written via Int32Array indexing rather than DataView, avoiding per-access endianness checks on the hot path.
npm install @nxtedition/shared
import { alloc, Reader, Writer } from '@nxtedition/shared'
// Allocate shared memory (pass handle to a worker thread)
const state = alloc(1024 * 1024) // 1 MB ring buffer
// --- Writer side (e.g. main thread) ---
const w = new Writer(state)
const payload = Buffer.from('hello world')
w.writeSync(payload.length, (data) => {
payload.copy(data.buffer, data.byteOffset)
return data.byteOffset + payload.length
})
// --- Reader side (e.g. worker thread) ---
const r = new Reader(state)
r.readSome((data) => {
const msg = data.buffer.subarray(data.byteOffset, data.byteOffset + data.byteLength).toString()
console.log(msg) // 'hello world'
})
w.cork(() => {
for (const item of items) {
const buf = Buffer.from(JSON.stringify(item))
w.writeSync(buf.length, (data) => {
buf.copy(data.buffer, data.byteOffset)
return data.byteOffset + buf.length
})
}
})
// All writes flushed atomically when cork returns
Or manually:
w.cork()
w.writeSync(buf1.length, writeFn, buf1)
w.writeSync(buf2.length, writeFn, buf2)
w.uncork() // publishes all writes to the reader
const buf = Buffer.from('data')
const ok = w.tryWrite(buf.length, (data) => {
buf.copy(data.buffer, data.byteOffset)
return data.byteOffset + buf.length
})
if (!ok) {
// Buffer is full — the reader hasn't caught up yet
}
// main.js
import { alloc, Writer } from '@nxtedition/shared'
import { Worker } from 'node:worker_threads'
const state = alloc(1024 * 1024)
const worker = new Worker('./reader-worker.js', {
workerData: state,
})
const w = new Writer(state)
// ... write messages
// reader-worker.js
import { Reader } from '@nxtedition/shared'
import { workerData } from 'node:worker_threads'
const r = new Reader(workerData)
function poll() {
const count = r.readSome((data) => {
// process data.buffer at data.byteOffset..data.byteOffset+data.byteLength
})
setImmediate(poll)
}
poll()
alloc(size)Allocates a shared ring buffer. Returns a handle that can be passed directly to Reader, Writer, and worker threads via workerData.
Overhead (state header + length header + alignment) is added automatically on top of size.
new Reader(handle)Creates a reader for the ring buffer.
reader.readSome(next, opaque?)Reads a batch of messages. Calls next(data, opaque) for each message, where data has:
buffer: Buffer — The underlying shared buffer
view: DataView — A DataView over the shared buffer
byteOffset: number — Start offset of the message payload
byteLength: number — Length of the message payload in bytes
opaque — Optional user-provided context object passed through to the callback. Useful for avoiding closures on hot paths.
Return false from the callback to stop reading early. Returns the number of messages processed.
Messages are batched: up to 1024 items or 256 KiB per call.
new Writer(handle, options?)Creates a writer for the ring buffer.
Options:
yield?: () => void — Called when the writer must wait for the reader to catch up. Useful to prevent deadlocks when the writer thread also drives the reader.logger?: { warn(obj, msg): void } — Logger for yield warnings (pino-compatible).writer.writeSync(len, fn, opaque?)Synchronously writes a message. Blocks (via Atomics.wait) until buffer space is available.
len bytes in the callback is undefined behavior.data.buffer starting at data.byteOffset. Must return the end position (data.byteOffset + bytesWritten), not the byte count.Throws on timeout (default: 60000 ms).
writer.tryWrite(len, fn, opaque?)Non-blocking write attempt. Returns false if the buffer is full. The fn and opaque parameters follow the same contract as writeSync.
writer.cork(callback?)Batches multiple writes. The write pointer is only published to the reader when the cork is released, reducing atomic operation overhead.
When called with a callback, uncork is called automatically when the callback returns. When called without a callback, you must call uncork() manually.
writer.uncork()Decrements the cork counter. When it reaches zero, publishes the pending write position to the reader. Safe to call when not corked (no-op).
writer.flushSync()Immediately publishes the pending write position to the reader. Unlike uncork, this does not interact with the cork counter — it forces a flush regardless.
Measured on AMD EPYC 9355P (4.29 GHz), Node.js 25.6.1, 8 MiB ring buffer, Docker (x64-linux).
Each benchmark writes batches of fixed-size messages from the main thread and
reads them in a worker thread. The shared ring buffer is compared against
Node.js postMessage (structured clone).
| Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) |
|---|---|---|---|---|
| 64 B | 838 MiB/s | 388 MiB/s | 24 MiB/s | 42 MiB/s |
| 256 B | 2.65 GiB/s | 1.46 GiB/s | 89 MiB/s | 168 MiB/s |
| 1 KiB | 4.95 GiB/s | 4.86 GiB/s | 339 MiB/s | 525 MiB/s |
| 4 KiB | 8.42 GiB/s | 15.11 GiB/s | 1.12 GiB/s | 1.86 GiB/s |
| 16 KiB | 12.02 GiB/s | 33.27 GiB/s | 4.12 GiB/s | 6.02 GiB/s |
| 64 KiB | 12.96 GiB/s | 43.66 GiB/s | 9.33 GiB/s | 14.73 GiB/s |
| Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) |
|---|---|---|---|---|
| 64 B | 13.73 M/s | 6.35 M/s | 391 K/s | 693 K/s |
| 256 B | 11.14 M/s | 6.14 M/s | 366 K/s | 689 K/s |
| 1 KiB | 5.19 M/s | 5.09 M/s | 348 K/s | 538 K/s |
| 4 KiB | 2.21 M/s | 3.96 M/s | 295 K/s | 488 K/s |
| 16 KiB | 788 K/s | 2.18 M/s | 270 K/s | 395 K/s |
| 64 KiB | 212 K/s | 715 K/s | 153 K/s | 241 K/s |
Small messages (64–256 B): The shared ring buffer with Buffer.set delivers
13.7–11.1 M msg/s — up to 35x faster than postMessage (buffer) and
20x faster than postMessage (string). Per-message overhead dominates at
these sizes, and avoiding structured cloning makes the biggest difference.
Medium messages (1 KiB): Buffer.set and string are nearly identical
(4.95 vs 4.86 GiB/s), both ~9x faster than the best postMessage
variant.
Large messages (4–64 KiB): Shared string overtakes Buffer.set and
scales to 43.7 GiB/s at 64 KiB — 3.4x faster than Buffer.set and
3.0x faster than postMessage (string). At every size, the shared ring
buffer outperforms postMessage.
Caveat: The string benchmark uses ASCII-only content. Multi-byte UTF-8 strings will not hit V8's vectorized fast path and will be significantly slower.
node --allow-natives-syntax packages/shared/src/bench.mjs
MIT
FAQs
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
Node.js is debating whether AI-driven security report volume warrants moving more vulnerability reports into public workflows.

Security News
PolinRider expands across npm, Packagist, Go modules, and Chrome extensions, using hidden loaders to target developer environments.

Security News
Open source attacks are accelerating as AI coding agents pull in dependencies faster, with less human review.