Transferable Objects & Zero-Copy

Every postMessage call has a hidden cost proportional to the size of what you send, because the default transport is the structured clone algorithm: it walks the object graph, allocates a fresh copy in the receiving thread’s heap, and blocks the sender for the duration. For a control message that cost is unmeasurable. For a 50 MB Float32Array it is a dropped frame — and then another one when the result comes back. Transferable objects remove that cost entirely by moving ownership of a memory region instead of duplicating it, in time that does not depend on the size of the region. This guide is part of the Web Workers Architecture & Communication reference, and it covers the one optimisation that changes the shape of a worker pipeline rather than just its constant factor.

Structured clone copy vs ArrayBuffer ownership transfer Structured clone duplicates the buffer into a new allocation on the worker heap; ownership transfer moves a pointer and neuters the source, leaving no copy in memory. Structured clone (copy) Main thread heap ArrayBuffer [50 MB] ~15–20 ms copy Worker heap ArrayBuffer copy [50 MB] 100 MB resident in memory Transfer (zero-copy) Main thread heap ArrayBuffer [50 MB] <1 ms pointer move Main thread heap detached — byteLength = 0 Worker heap ArrayBuffer [50 MB] — exclusive owner 50 MB resident — no duplication
Structured clone doubles peak memory usage and blocks for 15–20 ms per 50 MB; transferring ownership is sub-millisecond and consumes no additional memory.

The Failure This Guide Prevents

A LiDAR inspection viewer streams point clouds into the browser. Each scan is 1.5 million points — position, intensity and classification — which the app packs into a Float32Array of roughly 24 MB and hands to a worker for decimation, bounding-box computation and viewport culling. The worker returns a decimated buffer that WebGL uploads as a vertex attribute. The architecture is right: the expensive maths is off the main thread, and the worker’s own compute time is a stable 6 ms per scan. Yet the viewer stutters every time a scan arrives, and the profile explains why the worker is not to blame:

  • The main-thread track shows a 9 ms task with almost nothing in it except a postMessage call — no scripting of your own, just serialization.
  • A second, matching 9 ms task appears on the worker track before the handler runs, deserializing what the main thread just serialized.
  • Peak memory doubles for the duration of every scan, because sender and receiver both hold a full 24 MB copy while the clone is in flight.
  • The return leg repeats the whole thing in reverse, so a single scan costs roughly 30 ms of pure copying against a 16.7 ms frame budget.
  • Garbage-collection pauses grow over a long session: every scan allocates and discards two large buffers, and the collector eventually stops the world to reclaim them.

None of that is worker overhead in the abstract — it is memory bandwidth. A large-buffer clone is bounded by how fast the machine can allocate and memcpy, which on typical laptop hardware lands somewhere between 2 and 5 GB/s once allocation and page faults are included; 24 MB each way therefore costs on the order of 10 ms per leg and scales linearly with the payload. Transferring the same buffer takes a constant fraction of a millisecond regardless of whether it holds 1 MB or 500 MB, because nothing is copied: the engine detaches the backing store from the sending realm and re-attaches it in the receiving one. The measurement side of this — proving the clone is the cost before you change anything — is covered in Measuring Structured Clone Cost with performance.now().

One 24 MB scan on the timeline: cloned versus transferred Two stacked timelines share one axis marked in 16.7 millisecond frames. The cloned round trip spends 9 ms serializing on the main thread, 9 ms deserializing on the worker, 6 ms computing, then 6 ms serializing and 6 ms deserializing on the way back — 36 ms in total, 30 ms of which is copying, spilling over three frames. The transferred round trip is two sub-millisecond postMessage calls around the same 6 ms of compute, and fits inside the first frame. One 24 MB scan, measured end to end Structured clone — 36 ms, 30 ms of it copying Main thread Worker serialize 9 ms deserialize 9 ms compute 6 ms serialize 6 ms deserialize 6 ms Transfer — 6.2 ms, inside a single frame Main thread Worker 6 ms postMessage ×2 — under 0.2 ms total the 6 ms of real work, and nothing else Frame budget frame 1 — 16.7 ms frame 2 3 Only 6 of those 36 ms are the worker's actual work; the other 30 are memory bandwidth on both threads. Transfer deletes all four copy tasks, so the whole round trip lands inside a single frame.
The same scan, profiled twice. Cloning bills four serialize/deserialize tasks around 6 ms of real compute and overruns the frame budget nearly three times over; transferring leaves only the compute.

Prerequisites

Nothing here needs a flag or an experimental build, but a few things must be true before the pattern pays off:

  • Your payload is already binary, or can be. Transfer moves ArrayBuffer backing stores. An array of 1.5 million plain objects has to be packed into a typed array first; if you are still deciding on a wire format, Streaming JSON Parsing with Transferable Chunks covers doing the packing incrementally as data arrives.
  • Module workers. new Worker(url, { type: 'module' }) lets both threads import the same envelope types and keeps function names in stack traces. It needs Firefox 114+ if you support Firefox.
  • A typed envelope shared by both threads, so the transfer list and the message body cannot drift apart. The discipline is the same one described in Message Passing Strategies, and transfer makes it more important, not less: a renamed field that quietly stops matching the transfer list degrades into a silent copy rather than an error.
  • A messageerror handler on both sides. Deserialization failures do not fire message, and a request whose reply never arrives will hold a pooled buffer hostage forever.
  • An understanding of what clone does, because transfer does not replace it — the rest of the envelope is still cloned normally. The Step-by-Step Guide to the Structured Clone Algorithm walks the traversal rules that apply to everything outside the transfer list.
  • Optional: ArrayBuffer.prototype.transfer() (Chrome 114+, Firefox 122+, Safari 17.4+) if you want to detach a buffer deliberately outside of postMessage — useful for enforcing single ownership inside your own code.

Step 1 — Allocate the Buffer Once and Fill It In Place

Zero-copy at the thread boundary is worthless if you copy three times getting there. The bytes you intend to send must already live in the backing store you are going to transfer, which means allocating a typed array of the exact final length up front and writing into it — not building a JavaScript array and converting at the end, and not concatenating typed arrays with set() into successively larger buffers.

// packing.ts — shared by both threads
export const STRIDE = 5;                    // x, y, z, intensity, class

export interface Point { x: number; y: number; z: number; i: number; c: number; }

/** Packs points into a single Float32Array whose buffer is the unit of transfer. */
export function packPoints(points: readonly Point[]): Float32Array {
  // One allocation, exact size. `out.buffer` is what will move across the boundary.
  const out = new Float32Array(points.length * STRIDE);
  for (let n = 0; n < points.length; n++) {
    const p = points[n];
    const o = n * STRIDE;                   // write directly into the final backing store
    out[o] = p.x; out[o + 1] = p.y; out[o + 2] = p.z;
    out[o + 3] = p.i; out[o + 4] = p.c;
  }
  return out;
}

Two details matter more than they look. First, new Float32Array(n) allocates its own ArrayBuffer, so out.buffer is exactly n * 4 bytes with no other views on it — which is what makes it safe to hand away. Second, if you build the view over a larger shared buffer with new Float32Array(pool, byteOffset, length), transferring pool detaches every view on it, including ones other parts of your code still hold. One buffer per logical payload is the rule that keeps ownership legible.

Trade-off: exact sizing vs. unknown length

Allocating the exact size requires knowing the count in advance. When it is unknown — a stream, a parser, a filter — you have three options, in increasing order of complexity: over-allocate and send length as a cloned field alongside the buffer; use a resizable buffer (new ArrayBuffer(n, { maxByteLength }), Chrome 111+ / Firefox 128+ / Safari 16.4+) and grow it in place; or emit fixed-size chunks and transfer each as it fills. The chunked approach is usually best because it also spreads the work across frames — see How to Pass Large Arrays Without Blocking the UI.

Stride layout of the packed buffer, and what one transfer detaches A single ArrayBuffer is drawn as a row of cells. The first five cells hold x, y, z, intensity and class for point 0 (bytes 0 to 19); the next five hold the same fields for point 1 (bytes 20 to 39); the rest of the scan continues off to the right. Two typed-array views are drawn as bars over the same bar: a full-length positions view and a shorter tail view. Below, a dashed outline shows the same buffer after postMessage: detached, with byteLength zero, and both views reporting length zero. Stride layout — one buffer, many views point 0 — bytes 0…19 point 1 — bytes 20…39 x y z i c x y z i c 1.5 M points more x y z = position · i = intensity · c = class · STRIDE = 5 floats = 20 bytes byteOffset 0 byteLength ≈ 24 MB Every view is a window onto that one backing store positions — new Float32Array(buf) tail — new Float32Array(buf, 1200, n) One postMessage(msg, [buf]) detaches all of it ArrayBuffer detached — byteLength 0 positions.length === 0 tail.length === 0 reads return undefined Transferring a shared pool buffer takes every unrelated view with it — one buffer per logical payload.
Five floats per point in one allocation, and every view drawn over that allocation is a window, not a copy — which is why a single transfer zeroes all of them at once.

Step 2 — Hand Ownership Over in the Transfer List

postMessage takes the message as its first argument and a list of transferables as its second. Anything listed there moves; everything else in the envelope is cloned as usual. This is why the pattern composes so well with an ordinary typed protocol — you keep sending small readable objects, and only the bulk fields skip the copy.

// client.ts — main thread
export interface ScanRequest {
  kind: 'scan';
  id: number;
  stride: number;
  pointCount: number;
  buffer: ArrayBuffer;        // travels by reference in the transfer list
}

export function sendScan(worker: Worker, id: number, packed: Float32Array): void {
  const msg: ScanRequest = {
    kind: 'scan',
    id,
    stride: STRIDE,
    pointCount: packed.length / STRIDE,
    buffer: packed.buffer,
  };

  // Second argument is the transfer list: the raw ArrayBuffer, never the view.
  worker.postMessage(msg, [packed.buffer]);

  // From here on the main thread owns nothing: packed.length === 0.
  if (packed.buffer.byteLength !== 0) {
    console.warn('[transfer] scan %d was cloned, not transferred', id);
  }
}

The transfer list is a list of Transferable objects, and a typed array is not one of them. Passing packed instead of packed.buffer throws DataCloneError: Value at index 0 does not have a transferable type. The view can still travel in the message body — it clones to a tiny descriptor pointing at the buffer you transferred — but the list itself only ever takes the backing store.

The instant postMessage returns, the sending thread’s buffer is detached. byteLength reads 0, packed.length reads 0, indexed access returns undefined rather than throwing, and any method that walks the bytes — set, subarray, fill, slice — throws a TypeError. That asymmetry is worth internalising, because the silent-undefined path is how a detached read gets mistaken for a data bug: a chart that renders NaNs is far more likely to be reading a buffer it gave away than to have received bad numbers.

Trade-off: speed for exclusivity

Transfer buys constant-time hand-off at the price of single ownership. If the sending thread still needs the data — to redraw the current frame, to retry after a worker crash, or to diff against the next scan — transfer is the wrong tool and you must either clone deliberately, keep a second copy, or use shared memory. Reaching for slice() to "keep a copy and transfer the original" reintroduces exactly the memcpy you were trying to avoid, so make the decision once, at design time, rather than per call site.

Anatomy of one postMessage call The envelope passed as the first argument holds five fields: kind, id, stride and pointCount are copied by structured clone, while the buffer field is moved. On the worker side the same four scalars arrive as copies and the buffer arrives as the sole owner. Below, the second argument — the transfer list — accepts an ArrayBuffer but throws DataCloneError for a typed-array view. Anatomy of one postMessage call argument 1 — the envelope kind: 'scan' id: 41 stride: 5 pointCount: 1500000 buffer: ArrayBuffer cloned cloned cloned cloned moved copied moved one call, two transports worker — after delivery kind: 'scan' id: 41 stride: 5 pointCount: 1500000 buffer — sole owner copy copy copy copy 24 MB argument 2 — the transfer list [ packed.buffer ] — an ArrayBuffer moves [ packed ] — a view throws DataCloneError Everything outside the transfer list is still cloned; both mechanisms travel in the same call.
The transfer list is not an alternative to structured clone — it is a carve-out inside it. Four scalars are copied, one backing store changes hands, in a single call.

Step 3 — Transfer the Result Back

A worker that receives a buffer owns it. If it replies with a cloned result, the buffer it was given is garbage collected inside the worker and the main thread has to allocate a fresh one for the next scan — you have halved the copying, not eliminated it. Post the buffer back in the worker’s own transfer list and ownership ping-pongs between the two threads with no allocation on either side.

// scan-worker.js — module worker
self.onmessage = ({ data }) => {
  if (data.kind !== 'scan') return;

  const { id, stride, pointCount, buffer } = data;
  const points = new Float32Array(buffer, 0, pointCount * stride);

  // Decimate in place: keep every Nth point, compacting toward the front.
  const keepEvery = 4;
  let write = 0;
  for (let read = 0; read < pointCount; read += keepEvery) {
    points.copyWithin(write * stride, read * stride, read * stride + stride);
    write++;
  }

  // The reply reuses the same backing store — no new allocation anywhere.
  self.postMessage(
    { kind: 'scan:done', id, stride, pointCount: write, buffer },
    [buffer],                       // ownership returns to the main thread
  );
};

self.addEventListener('messageerror', (e) => {
  // A payload that failed to deserialize never reaches onmessage.
  self.postMessage({ kind: 'scan:error', id: null, reason: String(e.data) });
});

copyWithin is doing real work here: compaction happens inside the buffer the main thread already allocated, so the decimated result is a prefix of the same memory rather than a second array. The reply carries the new pointCount as an ordinary cloned number, and the main thread builds a view over the returned buffer using that length. The buffer stays physically 24 MB; only the meaningful range shrinks. If you genuinely need a smaller allocation you can slice() in the worker, but that is a copy — usually worth it only when the result will be retained for a long time.

Trade-off: round-trip latency is not zero

Transfer removes the size-proportional cost, not the hop. Each leg still queues a task on the receiving thread's event loop, and a full round trip costs roughly 0.1–0.5 ms even for an empty message. For a per-scan or per-frame pipeline that is negligible against a 16.7 ms budget. For a coordination protocol exchanging thousands of tiny messages per second it is the dominant cost, and shared memory with Atomics.notify (0.005–0.02 ms per signal) is the better instrument.

Ownership ping-pong across a worker round trip A main-thread lane and a worker lane share one buffer. The main thread packs a 24 MB buffer, transfers it to the worker with postMessage, the worker decimates in place with copyWithin, transfers it back, and the main thread uploads the same bytes to WebGL. A strip along the bottom marks the single physical 24 MB backing store, and a counter shows zero allocations in steady state. Ownership ping-pong — one backing store, three owners Main thread Worker packed buffer 24 MB, owned here ② decimate in place copyWithin — no alloc ④ upload to WebGL the same bytes ① postMessage(msg, [buf]) ③ postMessage(reply, [buf]) 0 allocs in steady state Memory one 24 MB backing store — never duplicated, never reallocated Reply with a cloned result instead and every scan allocates a second 24 MB buffer, then frees the first.
Ownership moves four times and the allocator is never involved: the worker compacts the result inside the buffer it was lent, then hands the same backing store back.

Step 4 — Recycle Buffers Through a Pool

Once ownership ping-pongs cleanly, the remaining cost is allocation. A pipeline that creates a fresh 24 MB buffer per scan generates large-object-space churn and periodic collector pauses even though no copying happens. A pool fixes this: pre-allocate a small number of buffers, lend one out per request, and reclaim it when the reply arrives. In steady state the pipeline allocates nothing.

The subtlety is that a lent-out buffer is detached locally, so the pool cannot hold a live reference to it while the worker owns it. It must track the slot, not the object, and re-seat the returned buffer into that slot.

// buffer-pool.ts
interface Slot { buffer: ArrayBuffer | null; inFlight: boolean; }

export class BufferPool {
  readonly #slots: Slot[];
  readonly #byteLength: number;

  constructor(size: number, byteLength: number) {
    this.#byteLength = byteLength;
    this.#slots = Array.from({ length: size }, () => ({
      buffer: new ArrayBuffer(byteLength),
      inFlight: false,
    }));
  }

  /** Returns a free slot index and its buffer, or null when the pool is saturated. */
  acquire(): { index: number; buffer: ArrayBuffer } | null {
    const index = this.#slots.findIndex((s) => !s.inFlight && s.buffer !== null);
    if (index === -1) return null;          // apply backpressure; do not allocate more
    const slot = this.#slots[index];
    slot.inFlight = true;
    const buffer = slot.buffer!;
    slot.buffer = null;                     // the local reference is about to detach
    return { index, buffer };
  }

  /** Re-seats a buffer the worker transferred back. */
  release(index: number, buffer: ArrayBuffer): void {
    const slot = this.#slots[index];
    if (!slot?.inFlight) return;            // late or duplicate reply — ignore it
    slot.buffer = buffer.byteLength === this.#byteLength ? buffer : new ArrayBuffer(this.#byteLength);
    slot.inFlight = false;
  }

  /** Replaces a slot whose buffer was lost with the worker that owned it. */
  reclaimLost(index: number): void {
    const slot = this.#slots[index];
    if (!slot) return;
    slot.buffer = new ArrayBuffer(this.#byteLength);
    slot.inFlight = false;
  }

  get available(): number { return this.#slots.filter((s) => !s.inFlight).length; }
}

acquire() returning null instead of allocating on demand is deliberate: a pool that grows under load is not a pool, it is a slow leak with extra steps. A saturated pool is a backpressure signal, and the correct response is to stop producing — drop the frame, coalesce with the next one, or queue the request until a slot frees. The same credit-window reasoning applies as in any streaming protocol, and pool sizing interacts directly with worker count; Worker Pool Management covers picking both numbers together. As a starting point, size the pool at one buffer per worker plus one, which keeps every worker fed while capping resident memory at a number you can state out loud.

reclaimLost exists because ownership transfer has an unforgiving failure mode: if the worker dies while holding a buffer, that memory is freed with the worker and the slot would otherwise stay inFlight forever. Wire it to the worker’s error handler and to the request timeout, and the pool degrades by re-allocating one buffer rather than deadlocking.

Trade-off: resident memory vs. pipeline depth

A pool trades a fixed memory floor for zero steady-state allocation. Four 24 MB buffers pin 96 MB for the life of the page whether or not the pipeline is busy, which is a real cost on a 2 GB mobile device. Prefer a shallow pool (2–4 slots) plus backpressure over a deep one, and release the pool entirely when the feature is not on screen. If pooled buffers keep growing rather than being reused, the diagnosis is in Identifying Memory Leaks in Workers.

Buffer pool slot lifecycle On the left, a four-slot pool of 24 MB buffers: two slots free, one in flight, one lost. On the right, the state machine each slot follows. Acquire moves a slot from free to in flight and nulls the local buffer reference; the worker's reply calls release and returns it to free. If the worker dies the slot becomes lost, and reclaimLost re-allocates a buffer to return that slot to free rather than leaving the pool deadlocked. Pool slot lifecycle — the slot is the unit of state new BufferPool(4, 24 MB) slot 0 slot 1 slot 2 slot 3 free in flight lost free available: 2 of 4 → apply backpressure Slot state machine free buffer resident in flight slot.buffer = null lost buffer gone acquire() release(i, buffer) worker error reclaimLost(i) — re-allocate A pool that grows under load is not a pool — a saturated pool is a backpressure signal, not an error.
Because a lent-out buffer is detached locally, the pool tracks slots rather than objects. The lost path is what stops a dead worker from stalling the pipeline forever.

Step 5 — Transfer the Other Transferables

ArrayBuffer is the transferable everyone learns first, but the interface covers several object types, and the non-buffer ones remove even bigger costs because they carry GPU or decoder resources rather than plain bytes.

// Decode an image off the main thread and hand the decoded bitmap over.
const response = await fetch('/scans/overlay.png');
const bitmap = await createImageBitmap(await response.blob());
worker.postMessage({ kind: 'overlay', bitmap }, [bitmap]);
// `bitmap` is now closed on this thread: bitmap.width === 0.

// Give the worker exclusive draw access to a canvas for the rest of the page's life.
const canvas = document.querySelector('canvas')!;
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ kind: 'attach-canvas', offscreen }, [offscreen]);

// Hand one end of a private channel to a second worker so the two talk directly.
const { port1, port2 } = new MessageChannel();
decoder.postMessage({ kind: 'sink' }, [port1]);
renderer.postMessage({ kind: 'source' }, [port2]);

Each has its own ownership semantics, and they are not interchangeable:

Transferable What actually moves Ownership after transfer Typical use
ArrayBuffer The backing store Sender detached, byteLength === 0 Bulk numeric payloads, packed records
ImageBitmap The decoded pixel handle, often GPU-resident Sender’s bitmap is closed (width === 0) Image filters, texture upload without re-decode
OffscreenCanvas The rendering surface Main thread permanently loses 2D/WebGL context access Chart and scene rendering off the main thread
MessagePort The endpoint of a channel Sender can no longer post on that port Worker-to-worker links, per-feature routing
ReadableStream / WritableStream / TransformStream The stream endpoint Sender loses the endpoint; chunks cross as they flow Piping fetch bodies straight into a worker
AudioData / VideoFrame (WebCodecs) The media frame’s backing memory Sender’s frame is closed Real-time codec pipelines

ImageBitmap is the highest-leverage of these for image work: createImageBitmap performs the decode once, and transferring the result means the worker never re-decodes and never touches ImageData’s four-bytes-per-pixel copy. Using Transferable Objects for Canvas ImageData covers the pixel-buffer route for cases where you need the raw samples instead.

OffscreenCanvas is the one to be careful with, because transferControlToOffscreen() is irreversible for the lifetime of that element: the main thread can never get a context back from that canvas, and calling getContext() on it afterwards throws. That is a feature when the worker owns rendering outright — see Transferring Canvas Control to a Worker — and a trap if any code path expected to draw an overlay from the main thread later.

Trade-off: irreversible hand-offs

ArrayBuffer ownership can ping-pong indefinitely; OffscreenCanvas cannot come back, and a closed ImageBitmap or VideoFrame cannot be reopened. Treat the non-buffer transferables as one-way architectural decisions, made once during setup, rather than per-message optimisations. If you need both threads to draw to the same visual output, use two stacked canvas elements instead of trying to share one.

Reversible versus one-way transferables Two groups. On the left, the reversible transferables: an ArrayBuffer can be sent to a worker and transferred back in the reply, and a MessagePort pair lets either end post to the other. On the right, the one-way transferables: ImageBitmap closes on the sender, OffscreenCanvas is permanent for the element's life, VideoFrame and AudioData close, and stream endpoints move — each drawn with a crossed-out return arrow. Hand-off shape — which transferables can come back Reversible — ownership can ping-pong ArrayBuffer transfer it back in the reply sender worker MessagePort either end can post to the other port1 port2 One-way — no path back ImageBitmap OffscreenCanvas VideoFrame / AudioData Readable / Writable stream sender closed permanent frame closed endpoint moves Only ArrayBuffer and MessagePort are safe to move per message; the rest are setup-time decisions.
The transfer list takes six kinds of object, but only two of them describe a hand-off you can undo. The other four are architecture, decided once during setup.

Choosing Between Clone, Transfer and Shared Memory

There are exactly three ways to get data across a worker boundary, and mature pipelines use all three, selected per message class rather than per application.

Situation Mechanism Cost profile Who owns the bytes afterwards
Small control message: start, cancel, config, IDs Structured clone Microseconds; proportional to graph size Sender keeps its copy
Bulk payload the sender is finished with Transfer list Constant, ~0.02–0.1 ms regardless of size Receiver exclusively; sender detached
Bulk payload the sender still needs Deliberate clone or slice() One memcpy, 2–5 GB/s Both, independently
Data read continuously by two or more threads SharedArrayBuffer + Atomics No copy, no hand-off; synchronisation cost only All threads concurrently
Rendering surface Transferable OffscreenCanvas One-off, permanent Worker only

The heuristic that resolves almost every case: clone what is small, transfer what is bulk and finished with, share what is read continuously by more than one thread. The middle case is the common one, which is why the transfer list — not shared memory — is the default optimisation for a worker pipeline. Shared memory removes the hand-off too, but replaces it with visibility and ordering problems you must solve yourself with SharedArrayBuffer & Atomics, and it drags a hard deployment requirement along with it. For a side-by-side decision rubric, postMessage vs SharedArrayBuffer: When to Choose Each walks the comparison with the same workload measured both ways.

One clarification that trips people up: transfer and clone are not exclusive within a single message. The transfer list applies only to the objects listed in it, while every other field of the same envelope is cloned normally. That is exactly what Step 2’s message does — a handful of cloned integers riding along with a 24 MB backing store that moves.

COOP / COEP required for SharedArrayBuffer

If you escalate from transferable buffers to SharedArrayBuffer, the document must be served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and every cross-origin subresource must opt in through CORS or Cross-Origin-Resource-Policy. Without cross-origin isolation SharedArrayBuffer is undefined — the failure appears at construction, in same-origin workers too. Gate that path on globalThis.crossOriginIsolated === true and keep the transferable path as the fallback, because isolation is routinely broken later by an embed or an analytics tag nobody classified as a transport dependency. Transferable ArrayBuffers carry no such requirement and work on any page.

Decision tree: clone, transfer or share Starting from a payload to send: if it is smaller than about 100 KB, use structured clone and the sender keeps its copy. Otherwise, ask whether the sender still needs the bytes. If not, use the transfer list and the receiver owns them. If it does, ask whether two threads read the data continuously: if not, clone on purpose with slice, which costs one memcpy; if so, use SharedArrayBuffer with Atomics, which needs cross-origin isolation. Choosing the transport, one message class at a time payload to send smaller than ~100 KB? sender still needs the bytes? read continuously by two threads? yes no no yes no yes structured clone sender keeps its copy transfer list receiver owns the bytes clone on purpose slice() — one memcpy SharedArrayBuffer + Atomics, no hand-off needs crossOriginIsolated Clone what is small, transfer what is bulk and finished with, share what two threads read at once.
Three transports, chosen per message class rather than per application. The middle branch — bulk data the sender is finished with — is the common one, and it is the transfer list.

Verification & Measurement

A failed transfer does not throw — it silently degrades into a clone, and the only visible symptom is that the frame budget is gone again. Three checks catch that, in increasing order of rigour.

Assert detachment at the send site. A transferred buffer reports byteLength === 0 on the sending thread. Anything else means the buffer in the list was not the buffer in the payload.

export function postTransferring(
  target: Worker | MessagePort,
  msg: { buffer: ArrayBuffer },
  label: string,
): void {
  const expected = msg.buffer.byteLength;
  target.postMessage(msg, [msg.buffer]);
  if (msg.buffer.byteLength === expected && expected > 0) {
    // Still intact ⇒ the transfer list did not include this exact buffer object.
    console.warn('[transfer] %s copied %d bytes instead of transferring', label, expected);
  }
}

Confirm the cost is flat in payload size. This is the definitive test, because it distinguishes constant-time transfer from linear-time clone without trusting any single measurement. Time the postMessage call across a size sweep — the clone curve rises linearly while the transfer curve stays flat.

for (const mb of [1, 4, 16, 64]) {
  const buf = new ArrayBuffer(mb * 1024 * 1024);
  const t0 = performance.now();
  worker.postMessage({ kind: 'bench', buffer: buf }, [buf]);
  console.log('%d MB → %.3f ms', mb, performance.now() - t0);
}
// Transferring: ~0.02–0.10 ms at every size.
// Cloning (drop the second argument): roughly 0.4–1.0 ms per MB, rising linearly.

Read the Performance panel for the shape, not the number. In a recording of the real pipeline, a working transfer looks like a short postMessage task on the sender followed almost immediately by the handler on the worker track. A clone looks like two long, near-identical tasks — one serializing, one deserializing — with a gap between them proportional to size. Attach to the worker isolate as described in Chrome DevTools Worker Debugging so both tracks are visible in the same recording.

Two more signals worth watching over a long session. Heap totals should stay flat across scans once the pool is in place: a sawtooth that climbs means buffers are being allocated per message rather than recycled. And the count of available pool slots should oscillate without trending downward — a monotonic decline is the signature of buffers being lost to worker restarts and never reclaimed, which will end in a saturated pool that never recovers.

postMessage cost against payload size, cloned versus transferred A log-log chart with payload size from 32 kilobytes to 64 megabytes on the horizontal axis and postMessage cost from 0.01 to 100 milliseconds on the vertical axis. The structured-clone line rises linearly with size, passing the 16.7 millisecond frame budget at around 24 megabytes and reaching roughly 45 milliseconds at 64 megabytes. The transfer line is essentially flat near 0.03 to 0.08 milliseconds across the whole range. The two cross at roughly 40 kilobytes, below which cloning is cheaper. Cost of one postMessage against payload size postMessage cost (ms, log scale) 100 10 1 0.1 0.01 32 KB 128 KB 512 KB 2 MB 8 MB 32 MB payload size (log scale) structured clone — linear in size transfer — flat at ~0.05 ms 16.7 ms frame budget crossover ≈ 40 KB below this, cloning wins the 24 MB scan The clone line is memory bandwidth; the transfer line is a pointer swap, so payload size barely moves it.
Sweep the payload size and the two curves separate on a log-log plot: clone is a straight rising line, transfer is flat. Below roughly 40 KB the clone is cheaper, which is why control messages should stay cloned.

Failure Modes & Error Handling

Four failures are specific to ownership transfer, and only one of them announces itself.

Reading a buffer you already sent. The most common bug and the quietest: indexed reads on a detached view return undefined, which becomes NaN the moment it enters arithmetic. There is no exception and no console warning. Any code that both sends a buffer and reads from it later needs an explicit guard, and the check is cheap enough to leave in production.

const isDetached = (b: ArrayBuffer): boolean => b.byteLength === 0;

function readSafely(view: Float32Array, index: number): number {
  if (isDetached(view.buffer)) throw new Error('read after transfer: buffer is detached');
  return view[index];
}

Sending the same buffer twice. A retry path, a duplicate event handler, or a pool slot released early will attempt to transfer an already-detached buffer, and this one does throw — DataCloneError: ArrayBuffer at index 0 is already detached. Wrap the send so the log identifies which request did it, and never swallow the error, because the request it belonged to will otherwise hang forever.

try {
  worker.postMessage(msg, [msg.buffer]);
} catch (err) {
  if (err instanceof DOMException && err.name === 'DataCloneError') {
    console.error('[transfer] buffer for request %d was already given away', msg.id);
    pool.reclaimLost(slotIndex);          // the slot is unusable; replace it
  }
  throw err;
}

Non-detachable buffers. WebAssembly.Memory#buffer cannot be transferred at all — the spec marks it non-detachable, and attempting it throws DataCloneError. The same applies to a SharedArrayBuffer, which is shared by reference rather than transferred. When moving results out of a WebAssembly module, copy the region of linear memory into a fresh ArrayBuffer first and transfer that; the copy is unavoidable but it is bounded by the result size rather than the whole heap.

A worker that dies holding your memory. Transfer creates a hard dependency on the receiving thread staying alive. If the worker throws at top level, is killed under memory pressure, or is terminated mid-flight, the buffer goes with it and the sender has nothing to retry from. Every request that lends out a buffer therefore needs a timeout and an error path that reclaims the slot:

worker.addEventListener('error', (e) => {
  console.error('[transfer] worker died holding %d buffers', pending.size, e.message);
  for (const { slotIndex, reject } of pending.values()) {
    pool.reclaimLost(slotIndex);          // replace the lost memory
    reject(new Error('worker terminated before returning buffer'));
  }
  pending.clear();
  respawn();
});

Because the data is gone rather than merely delayed, retry semantics differ from an ordinary message protocol: the request cannot be replayed unless the sender still holds the source data in some other form. Decide up front whether a lost payload is regenerable — a scan that can be re-fetched — or fatal, and surface the difference in the UI rather than retrying blindly. The broader restart and telemetry patterns live in Main Thread vs Worker Thread Lifecycle.

The four failure modes of ownership transfer A two-by-two matrix. Reading a buffer you already sent is silent: indexed reads return undefined and become NaN, guarded by a byteLength zero check. Sending the same buffer twice throws DataCloneError, already detached, guarded by wrapping the send and reclaiming the pool slot. A non-detachable buffer — WebAssembly memory or a SharedArrayBuffer — throws DataCloneError on the first attempt, guarded by copying the region into a fresh ArrayBuffer first. A worker that dies holding your memory never replies at all, guarded by a timeout plus an error handler that calls reclaimLost. Only the silent case has no exception, which is why it is marked loudest. Four failure modes — only one of them stays quiet Read a buffer you already sent trigger · any code path that sends, then reads symptom · silence — reads give undefined, then NaN guard · assert byteLength !== 0 before every read no exception, no console warning — cheap enough to leave in production Send the same buffer twice trigger · retry, duplicate listener, early release symptom · DataCloneError: already detached guard · wrap the send, log the request id, reclaim the slot never swallow it — the request it belonged to will hang forever Non-detachable backing store trigger · WebAssembly.Memory#buffer, SharedArrayBuffer symptom · DataCloneError on the first attempt guard · copy the result region into a fresh ArrayBuffer one bounded memcpy of the result, not of the whole linear memory Worker dies holding your memory trigger · top-level throw, OOM kill, terminate() mid-flight symptom · no reply, slot stuck in flight forever guard · request timeout plus error handler → reclaimLost(i) the data is gone, not delayed — decide up front if it is regenerable Two of the four announce themselves as DataCloneError; the quiet one is the bug you will chase for a day.
The failure modes ownership transfer adds, ranked by how hard they are to notice. Read-after-transfer is the expensive one precisely because nothing throws.

Browser Compatibility

Feature Chrome Firefox Safari Edge
Transferable ArrayBuffer in postMessage 17+ 18+ 5.1+ 12+
MessagePort transfer 4+ 41+ 5+ 12+
ImageBitmap transfer 50+ 42+ 15+ 79+
OffscreenCanvas transfer 69+ 105+ 16.4+ 79+
Transferable streams 87+ 103+ 16.4+ 87+
structuredClone(value, { transfer }) 98+ 94+ 15.4+ 98+
ArrayBuffer.prototype.transfer() 114+ 122+ 17.4+ 114+
Resizable ArrayBuffer (maxByteLength) 111+ 128+ 16.4+ 111+
AudioData / VideoFrame transfer (WebCodecs) 94+ 130+ 16.4+ 94+
Module workers ({ type: 'module' }) 80+ 114+ 15+ 80+
SharedArrayBuffer (cross-origin isolated) 92+ 79+ 15.2+ 92+

The core pattern — Steps 1 through 4, packing into one buffer and ping-ponging ownership through a pool — has been universally supported for over a decade and needs no feature detection. The cliffs are all in the extensions: OffscreenCanvas transfer is the sharpest one at Firefox 105+, transferable streams and WebCodecs frames are recent enough to warrant a fallback path, and ArrayBuffer.prototype.transfer() is a convenience you can polyfill with a slice() plus an explicit hand-off. Build the pipeline on transferable ArrayBuffers, feature-detect the rest, and the same code ships to every engine in the table.


Going Further

The mechanism’s failure surface is narrow and unusual: a transferred buffer is not invalid, it is empty, and reads return undefined rather than throwing. Detached Buffer Errors and How to Avoid Them covers the four symptoms, the ownership conventions that prevent them, and a recycling protocol that hands buffers back so a long pipeline stops allocating.

Frequently Asked Questions

What happens to the original buffer after it is transferred?
It is detached — often called neutered. buffer.byteLength drops to 0, every typed-array view built on it reports length === 0, indexed reads return undefined, and methods such as set() or subarray() throw a TypeError. Constructing a new view over a detached buffer also throws. Ownership now belongs exclusively to the receiving thread, and the only way to get the bytes back is for that thread to transfer them to you again. Asserting buffer.byteLength === 0 immediately after postMessage in development is the cheapest way to prove the transfer actually happened.
Can I put a Float32Array or Uint8Array in the transfer list?
No. The transfer list accepts only objects that implement the Transferable interface — ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, the stream types, and WebCodecs’ AudioData / VideoFrame. A typed array is a view, not a transferable, and passing one raises DataCloneError: Value at index 0 does not have a transferable type. Pass view.buffer instead. The view itself can still travel in the message body, where it is cloned cheaply as a descriptor over the buffer you transferred.
Can I transfer only part of an ArrayBuffer?
Not directly — transfer moves an entire backing store. To send a sub-range, either call .slice(start, end) to build a new buffer (which copies the range once, then transfers in constant time), or transfer the whole buffer and carry byteOffset and length as ordinary cloned fields in the envelope so the receiver only reads the region that matters. The second option is free; the first costs one memcpy of the slice, which is still far cheaper than cloning the whole buffer.
Why did my transfer throw DataCloneError even though I passed an ArrayBuffer?
Three buffers look like ordinary ArrayBuffers but refuse to transfer: one that has already been detached by an earlier postMessage, a SharedArrayBuffer (shared memory is never transferred — it is cloned by reference), and the buffer exposed by WebAssembly.Memory#buffer, which is explicitly non-detachable. Re-sending a buffer you have already handed away is by far the most common cause, and it usually means a pool entry is being reused before the worker returned it.

See also