Structured Clone vs JSON: Faster Worker Message Passing in Node

Structured Clone vs JSON: Faster Worker Message Passing in Node

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]) with ArrayBuffer/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

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