JSON.stringify + JSON.parse across worker_threads is the default junior pattern — and a silent CPU tax once payloads leave the kilobyte range. Structured clone (what postMessage already uses) plus transferable ArrayBuffers moves ownership without copying. The unfair advantage is measuring the serialize tax, then redesigning the message shape so the hot path never stringifies.
⚡ TL;DR: Stop round-tripping through JSON for worker IPC; use
postMessage(value, [transferList])withArrayBuffer/MessagePort; keep control metadata small and cloneable; reserve JSON for logs and HTTP. Pair with Node Worker Threads and Zero-Copy Node Streams.
Measure the stringify tax
// scripts/bench-ipc.ts
import { Worker } from "node:worker_threads";
import { performance } from "node:perf_hooks";
const bytes = Buffer.alloc(1024 * 1024, 1);
function benchJson() {
const t0 = performance.now();
for (let i = 0; i < 200; i++) JSON.parse(JSON.stringify({ bytes: bytes.toString("base64") }));
return performance.now() - t0;
}
function benchClone() {
const t0 = performance.now();
for (let i = 0; i < 200; i++) structuredClone({ bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) });
return performance.now() - t0;
}
console.log({ jsonMs: benchJson().toFixed(1), cloneMs: benchClone().toFixed(1) });
// ❌ postMessage(JSON.stringify(huge)) — double tax + string bloat
worker.postMessage(JSON.stringify({ frames }));
Transfer buffers, clone metadata
// src/worker-pool.ts
import { Worker } from "node:worker_threads";
export function submitJob(worker: Worker, meta: { jobId: string }, payload: Buffer) {
const ab = payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength);
// ✅ Transfer moves ownership — main thread must not touch `ab` after
worker.postMessage({ type: "job", meta, payload: ab }, [ab]);
}
// worker.js
import { parentPort } from "node:worker_threads";
parentPort!.on("message", (msg) => {
if (msg.type !== "job") return;
const view = Buffer.from(msg.payload);
const result = processBytes(view);
const out = result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength);
parentPort!.postMessage({ type: "done", jobId: msg.meta.jobId, out }, [out]);
});
What clones, what fails
| Value | Structured clone | Transferable |
|---|---|---|
| Plain objects / arrays | Yes | No |
ArrayBuffer / TypedArray buffer |
Yes (copy) | Yes (move) |
MessagePort |
Yes | Yes |
| Functions / DOM nodes / sockets | No | No |
SharedArrayBuffer |
Share (not copy) | Special |
Closing checklist
✅ Dos
– ✅ Benchmark JSON vs structured clone on real payload sizes
– ✅ Transfer large buffers; clone tiny metadata
– ✅ Document ownership: after transfer, old view is neutered
– ✅ Fail fast on non-cloneable types in tests
– ✅ Keep SAB protocols separate from clone IPC
❌ Don’ts
– ❌ Don’t JSON.stringify worker messages “for simplicity”
– ❌ Don’t base64 buffers inside JSON for IPC
– ❌ Don’t touch transferred buffers after postMessage
– ❌ Don’t assume structuredClone deep-copies class instances with methods
Related reading
- Node Worker Threads: SharedArrayBuffer Protocols Without Data Races
- Zero-Copy Node Streams: Pipe Large S3 Objects Without Buffering
- N-API Addons for Node: Move Hot Loops Off the Event Loop
- Node libuv Threadpool Tuning: Size UV_THREADPOOL_SIZE With Benchmarks
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
