Transferring ArrayBuffers vs Copying Between Workers

A postMessage() call carrying a large binary payload between threads is silently doubling peak memory because worker threads memory isolation means every message defaults to a full copy unless you tell V8 otherwise, a cost that adds up fast in Node.js server-side memory management.

Symptom Root Cause Immediate Action
RSS jumps ~2x payload size per message Structured clone duplicates the buffer Add buffer to transferList
TypeError on detached buffer access Buffer reused after it was transferred Copy first if sender needs it too
Worker never sees the sender’s later writes Transfer assumed to behave like shared memory Use SharedArrayBuffer for concurrent access
GC pause spikes right after a big send Two live copies awaiting collection Transfer one-way handoffs instead of copying
external memory grows and drains slowly Clone sits uncollected until next major GC Confirm via process.memoryUsage().external

Root Cause

postMessage() has one default behaviour regardless of payload shape: everything reachable from the message argument is walked and rebuilt by the structured clone algorithm on the other side. For plain objects that is cheap enough to ignore. For an ArrayBuffer carrying tens or hundreds of megabytes of binary data — a decoded image, a parsed CSV, an audio buffer, a protobuf payload — cloning means V8 allocates a second backing store in the receiving isolate and copies every byte into it before your worker’s message handler even fires. For the brief window between the sender starting the clone and the original becoming eligible for collection, the same logical bytes exist twice: once in the sender’s heap and once in the receiver’s, plus whatever serialization buffer sits on the MessagePort in transit. That transient doubling is what shows up as an RSS spike in production, and because it happens on every message rather than once, a hot path that forwards buffers between a producer worker and a consumer worker compounds the cost per request.

The escape hatch is the second, often-forgotten argument to postMessage(): transferList. Any ArrayBuffer named in that array is not cloned — its backing store’s pointer is handed directly to the receiver, and the sender’s own view over it is detached. Detachment means the sender’s byteLength becomes 0 and any typed array built on it throws if touched again. This is a genuine ownership transfer, not a loan: once you transfer a buffer you cannot read it back from the sender, so the decision to transfer only makes sense for one-way handoffs where the sender is done with the data. If both sides genuinely need the same bytes at the same time — a shared counter, a lock-free ring buffer, a frame both threads read — transfer is the wrong tool entirely; that case belongs to sharing memory with SharedArrayBuffer, where the backing store is mapped by both isolates simultaneously and never copied or detached.

Copying is not always a mistake, though, and treating transfer as universally correct creates its own bugs. If you must fan the same buffer out to several workers — broadcasting a shared configuration blob, or handing independent chunks of a dataset to a worker pool where each needs its own mutable copy — transfer can only move ownership to one destination. Attempting to transfer the same buffer twice throws, because the first transfer already detached it. In that scenario an explicit slice() copy per destination is correct, and is no worse than what structured clone would have done anyway; the cost you are eliminating by transferring is the implicit, invisible copy that happens when you forget the second argument, not the deliberate copy you choose for a genuine one-to-many fan-out.

RSS Over Time: Structured-Clone Copy vs Transfer A timeline chart shows two lines tracking process RSS in megabytes as a large ArrayBuffer crosses from a sender to a worker. The copy line rises sharply to roughly double the payload size at the send event, then decays back down once garbage collection reclaims the duplicate. The transfer line stays essentially flat across the same send event because ownership moves without any duplicate allocation. Time (message sent at t1) RSS (MB) baseline 2x payload t1: postMessage(buf, ...) copy: clone + GC decay transfer: no spike copy (default) transfer (transferList)

Step-by-Step Fix

  1. Baseline the RSS delta of the current call. Wrap the existing postMessage() call with two samples of process.memoryUsage().rss, one immediately before and one ~50 ms after the call returns, and log the difference in MB. Verification: for a payload of size N MB sent by default (no transferList), the delta should land close to N MB or higher, confirming a copy is happening.
  2. Confirm the buffer is being cloned rather than transferred. Search the call site for the postMessage(value) signature with only one argument. If an ArrayBuffer or a typed array’s .buffer is reachable from value and no second array argument is present, it is cloned. Verification: grep the codebase for .postMessage( calls that pass an object containing a buffer field without a matching [buf] second argument.
  3. Add the buffer to transferList. Change the call to port.postMessage({ payload: buf }, [buf]), listing every ArrayBuffer (not the typed array view, the underlying .buffer) that should move rather than clone. Verification: the call still succeeds and the worker’s message event fires with the same byte content.
  4. Verify the sender is detached. Immediately after the postMessage() call, read buf.byteLength on the sender. Verification: it must read 0; if it still reports the original size, the buffer was not actually included in transferList (a common mistake is transferring a typed array view instead of its .buffer).
  5. Re-measure and gate the regression. Repeat step 1’s RSS sampling around the transfer call. Verification: the delta should now sit within a few MB of zero regardless of payload size, since no duplicate backing store is allocated; add this as an assertion in a test so a future refactor that drops transferList fails CI rather than shipping a silent memory regression.

Command & Code Reference

Benchmarking the RSS difference between a copied send and a transferred send of the same buffer size.

const { Worker } = require('node:worker_threads');

// mb() converts bytes to a readable MB figure for logging
const mb = (b) => (b / 1024 / 1024).toFixed(1);

function bench(mode, sizeMb) {
  const w = new Worker('./echo-worker.js');
  const buf = new ArrayBuffer(sizeMb * 1024 * 1024);

  const before = process.memoryUsage().rss;
  if (mode === 'copy') {
    w.postMessage({ buf }); // no transferList -> clones
  } else {
    w.postMessage({ buf }, [buf]); // moves ownership
  }
  // Sample after the microtask queue settles the send
  setImmediate(() => {
    const after = process.memoryUsage().rss;
    console.log(
      `${mode}: RSS delta ${mb(after - before)} MB ` +
      `(sender detached: ${buf.byteLength === 0})`
    );
    w.terminate();
  });
}

bench('copy', 50);     // expect ~50 MB+ delta
bench('transfer', 50); // expect near-zero delta

Receiving worker that echoes the payload size back, used by the benchmark above.

// echo-worker.js
const { parentPort } = require('node:worker_threads');

parentPort.on('message', ({ buf }) => {
  // buf here is a fresh, fully usable ArrayBuffer either way
  parentPort.postMessage({ receivedBytes: buf.byteLength });
});

When copy is the correct choice: broadcasting the same configuration blob to a pool of workers, since transfer can only hand ownership to one destination.

const { Worker } = require('node:worker_threads');

function broadcastConfig(configBuf, workers) {
  for (const w of workers) {
    // slice() makes an independent copy per worker;
    // transferring configBuf itself would only reach
    // the first worker and detach it for the rest
    const copy = configBuf.slice(0);
    w.postMessage({ config: copy }, [copy]);
  }
}

Verification & Regression Prevention

Track two numbers per deploy: the RSS delta around large postMessage() calls (target: within 5 MB of zero for any transferred payload, regardless of size) and process.memoryUsage().external sampled every few seconds under load (target: it should not grow linearly with request volume if buffers are being transferred correctly — a linear climb means copies are still leaking through). Add a CI check — a small Vitest or Jest test — that spawns the real worker, sends a known-size buffer through the production call site, and asserts buffer.byteLength === 0 immediately after; this catches a future edit that removes transferList before it reaches production. Pair it with a simple ESLint rule (or a grep-based pre-commit hook) that flags any .postMessage( call passing an object with a buffer-typed field but no second array argument, since that pattern is the single most common regression path back to an implicit copy.

Frequently Asked Questions

Does postMessage copy an ArrayBuffer by default?

Yes. Unless you list the buffer in the second transferList argument, postMessage runs the structured clone algorithm on it, allocating a full duplicate backing store in the receiving worker’s memory. A 30 MB buffer sent this way briefly costs roughly 60 MB across the two threads plus a serialization copy in between.

Can I still use a buffer after transferring it?

No. Transfer detaches the ArrayBuffer in the sender: its byteLength becomes 0 and any typed array view over it throws a TypeError on access. If both sides need the data afterwards, either copy explicitly with buf.slice() before transferring the copy, or use a SharedArrayBuffer instead.

How much memory does transferring actually save?

Transfer avoids allocating the duplicate entirely, so the saving equals the payload size itself, not a percentage. Moving a 100 MB buffer by transfer costs no extra backing-store allocation; copying the same buffer costs a second 100 MB allocation plus GC pressure to reclaim it once the clone is later collected.