Lesson 0003 · ~12 minutes

The channel tax: clone, transfer, cost

One skill: decide when plain postMessage is fine, when to transfer ownership, and when the message design (patches / chunking) is the real fix.

Win for this lesson

In a design review you can say: “default is structured clone; large binaries transfer; huge/high-frequency state needs patches — not more workers.”

1. Three ways data crosses the boundary

Clone

Default. Structured clone copies the value. Both sides can keep using their own object. Cost grows with size/complexity.

Transfer

Move ownership of a resource (e.g. ArrayBuffer). Zero-copy for the memory; sender’s buffer is detached (byteLength === 0).

Share

SharedArrayBuffer + Atomics: both sides see the same memory. Powerful, fussy (isolation headers), easy to race. Later / advanced — not your default.

MDN: data is copied, not shared, unless you opt into transfer or true shared memory.

2. Structured clone: what survives

The structured clone algorithm is smarter than JSON (cycles, Map/Set, buffers, many web types) but not magic:

Usually OK Throws / fails
Plain objects, arrays, primitives (not symbols), Date, Map, Set, typed arrays, ArrayBuffer, Blob, File, … Functions, DOM nodesDataCloneError
Many marked “serializable” Web API types Prototype chain, getters/setters metadata, private fields not preserved as live class instances
Design implication

Don’t post “smart” class instances hoping methods come along. Send data (DTOs). Rebuild behavior on each side. Same lesson as lesson 2’s typed messages — the wire carries values, not behavior.

3. Transferables: ownership moves

From MDN Transferable objects: after transfer, the original no longer owns the resource; reads/writes throw (buffers show byteLength === 0).

// 8 MB of pixels (or CSV bytes, audio, WASM heap slice, …)
const u8 = new Uint8Array(1024 * 1024 * 8);
// fill u8…

// Put the buffer in BOTH the message and the transfer list
worker.postMessage(
  { type: "process", bytes: u8 },
  [u8.buffer] // ownership moves
);

console.log(u8.byteLength); // 0 — detached on main thread

4. Is postMessage “slow”? (system answer)

Surma’s measurements (and the same framing on web.dev OMT):

When clone cost hurts — fix the message design

1) Patches — send diffs, not the whole state.
2) Chunking — stream partial results so UI can paint (PROXX-style).
3) Transfer — move big binaries with ownership.
4) Shared memory only when you truly need concurrent access (advanced).

5. Light hands-on — predict the outcome

Mentally run each snippet. Click the result that matches the platform model (not what you wish were true).

Snippet A

const a = [1]; worker.postMessage(a); a[0] = 9; — worker reads e.data[0]. What value?

Snippet B

worker.postMessage({ run: () => 1 }) — what happens?

Snippet C

After worker.postMessage(u8, [u8.buffer]), main thread reads u8.byteLength.

Scenario D

Editor keeps a 2 MB document object in a worker. Every keystroke posts the full document to main for render.

6. Decision rule (steal this)

Default rule

Small control messages and modest state → plain clone. Large binary blobs you won’t need again on this side → transfer. Huge or high-frequency logical state → redesign the payload (patches/chunks), not “avoid workers.” Reach for shared memory only with a clear concurrent-access need and isolation plan.

7. What to remember

Ask your teacher Transfer + ImageBitmap pipelines, correlating RPC over postMessage, or whether your app’s state should patch — ask in chat.

Primary source (read next)

MDN — Transferable objects (ownership model + examples). Pair with Surma — Is postMessage slow? for cost framing, and skim structured clone for the “what can’t cross” list.