Transferable Objects

TL;DR / Transferable objects allow zero-copy transfer of ownership for ArrayBuffer, MessagePort, OffscreenCanvas, and other resources between threads, avoiding the cost of structured cloning.

How It Works

 ┌────────────────┐                  ┌────────────────┐
 │  Main Thread   │                  │ Worker Thread  │
 └────────────────┘                  └────────────────┘


 ┌────────────────┐                  ┌────────────────┐
 │  ArrayBuffer   │zero-copy transfer│  ArrayBuffer   │
 │    (owned)     │─────────────────→│  (now owned)   │
 └────────────────┘                  └────────────────┘

               postMessage(buf, [buf])

 ┌────────────────┐
 │  ArrayBuffer   │
 │  (detached!)   │
 └────────────────┘

Edit diagram

When you send data between threads via postMessage, the default behavior is structured cloning — a deep copy of the object graph. For large data, this is expensive: copying a 100MB ArrayBuffer means allocating 100MB of new memory and memcpy-ing the contents. Transferable objects bypass this entirely by moving ownership of the underlying resource from one thread to another without copying any data.

The Transfer Mechanism

Transfer is invoked by passing an array of transferable objects as the second argument to postMessage: worker.postMessage(message, [arrayBuffer]). The first argument is the message payload (which may reference the transferable). The second argument is the transfer list — an array of objects whose ownership should be moved rather than cloned.

After transfer, the original reference on the sending side becomes "detached" — an ArrayBuffer with byteLength of 0 that throws on any read or write attempt. The receiving side gets full ownership of the resource. No data is copied; only the internal pointer to the memory is moved from one thread's context to another. This is effectively a pointer swap at the engine level, making it O(1) regardless of the buffer size.

What Is Transferable

The Transferable interface is implemented by several types:

ArrayBuffer — the most common case. Transfer moves the backing store. All typed arrays (Uint8Array, Float32Array, etc.) that share the same ArrayBuffer become useless on the sender side after transfer.

MessagePort — one end of a MessageChannel. Transferring a port lets you establish direct communication channels between workers without routing through the main thread.

OffscreenCanvas — transfers rendering control to a worker. Once transferred, the main thread can no longer draw to the canvas.

ImageBitmap — a decoded image ready for GPU upload. Transferring avoids re-decoding image data.

ReadableStream / WritableStream / TransformStream — streaming data between threads without buffering entire payloads in memory.

VideoFrame / AudioData — media frames for real-time processing pipelines.

The New structuredClone Transfer Syntax

The structuredClone() function also supports transfers: structuredClone(value, { transfer: [buffer] }). This is useful for transferring ownership within the same thread — for example, detaching an ArrayBuffer from a typed array to prevent further mutation, or passing a buffer to a utility function that should take exclusive ownership.

Performance Characteristics

Cloning a 50MB ArrayBuffer takes roughly 10-50ms (allocation plus memcpy). Transferring the same buffer takes microseconds — it is effectively free. For small buffers (under a few KB), structured cloning overhead is negligible.

The break-even point depends on your latency budget. For real-time applications (audio processing, 60fps game loops), even cloning moderate buffers can blow the frame budget. For batch processing, cloning smaller payloads is simpler and acceptable.

Ownership Model

Transfer enforces a strict single-owner model. At any given time, exactly one thread owns the resource. This eliminates data races (unlike SharedArrayBuffer, where multiple threads access the same memory concurrently). The trade-off is that the sending thread loses access entirely.

This maps naturally to a pipeline pattern: the main thread creates data, transfers it to Worker A, Worker A transfers the result to Worker B, and Worker B transfers the final result back. Each stage has exclusive access to the buffer it is working on.

Combining with SharedArrayBuffer

Transferable objects and SharedArrayBuffer are complementary, not competing. SAB enables concurrent read/write access to shared memory with Atomics for synchronization. Transferables enable zero-cost handoff of exclusive resources between threads. In a sophisticated multi-worker architecture, you might use SAB for shared state (configuration, counters, status flags) while using transferable ArrayBuffers for passing large work items through a processing pipeline.

Common Patterns

The round-trip pattern is the most prevalent: the main thread transfers an ArrayBuffer to a worker, the worker processes it and transfers it back. The same buffer is reused with zero allocation cost after initial creation — just two pointer swaps per round trip.

The pool pattern pre-allocates a set of ArrayBuffers and circulates them between threads. The main thread takes a buffer from the pool, fills it, and transfers it. When the worker finishes, it transfers the buffer back. This avoids allocation churn entirely.

Gotchas

  • The sender loses access — after transferring an ArrayBuffer, the original reference has byteLength === 0 and any typed array views over it become unusable. Attempting to read or write throws a TypeError. This catches developers who expect shared access.
  • Typed arrays are not transferable — you transfer the underlying ArrayBuffer, not the Uint8Array or Float64Array view. If multiple typed arrays share the same buffer, transferring that buffer invalidates all of them on the sender side.
  • Transfer list must match message references — if you include an ArrayBuffer in the transfer list but it is not referenced in the message object, the receiving side gets the message without the buffer. The buffer is still detached from the sender.
  • Cannot transfer SharedArrayBufferSharedArrayBuffer is designed for concurrent access and is always cloned by reference (both sides keep access). Putting a SAB in the transfer list throws a DataCloneError.
  • Neutered buffers silently break code — code that caches a reference to a transferred buffer and later tries to read it will fail. Always nullify references after transfer to catch issues early: buffer = null.