HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 1132 min read

Transferable Objects (postMessage & Structured Clone)

Why Your Web Worker Made Things Slower

Web developmentweb development / performance

The scenario

You moved image filtering into a Web Worker. The main thread no longer blocks on the pixel loop — yet the UI still stutters, and end-to-end time went UP: 620ms → 810ms.

You profile the main thread and find two mystery blocks: ~95ms before the worker starts, ~90ms after it replies. Neither is your code.

What's actually happening

postMessage does not share data. It copies it, via the structured clone algorithm. A 4000×3000 RGBA image is 48 MB. You pay that serialization twice — once in, once back — and structured clone runs synchronously on the sending thread.

You moved the filter off the main thread and left the copy on it.

Rough number: structured clone runs at ~0.5–1 GB/s. 48 MB ≈ 50–100ms. Each way.

The fix: transfer, don't clone

Some objects are transferable — ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream. Transferring hands over OWNERSHIP of the memory: no copy, constant time.

// main.js
const buf = imageData.data.buffer;         // 48 MB
worker.postMessage({ buf, w, h }, [buf]);  // 2nd arg = transfer list
console.log(buf.byteLength);               // 0  ← detached

// worker.js
onmessage = (e) => {
  const px = new Uint8ClampedArray(e.data.buf);
  filter(px);
  postMessage(e.data.buf, [e.data.buf]);   // hand it back
};

~95ms → ~0.1ms. The buffer is moved, so the sender's view is detached; touching it after throws. That's the trade: zero copy, but you no longer own it.

What trips people up

  • The transfer list is everything. postMessage({buf}, [buf]) transfers. postMessage({buf}) clones. Same object graph, totally different cost.
  • A typed array is NOT transferable — its .buffer is. Send arr.buffer, rebuild the view on the other side.
  • Transferring a slice of a pooled buffer detaches the WHOLE buffer. Don't transfer memory something else still reads.
  • Functions, DOM nodes and class prototypes don't survive structured clone at all — you get a plain object, or a DataCloneError.
  • "Many small messages" is not the escape hatch: per-message overhead is ~0.05ms, so 10k tiny posts cost more than one 10 MB transfer.

When you need shared, not moved

If both threads must read the same memory simultaneously, that's SharedArrayBuffer — but it requires cross-origin isolation (COOP: same-origin + COEP: require-corp), and you own the synchronization with Atomics. Transferables cover most real cases; reach for SAB only when ping-pong ownership genuinely doesn't fit.

Your exercise

Allocate a 64 MB ArrayBuffer. Time postMessage to a worker twice — once with it in the transfer list, once without. Log buf.byteLength immediately after each send. Then read the transferred buffer afterwards and look at the exact error. That detach is the whole mental model in one line.