Lesson 0003 · ~12 minutes
One skill: decide when plain postMessage is fine, when to
transfer ownership, and when the message design
(patches / chunking) is the real fix.
In a design review you can say: “default is structured clone; large binaries transfer; huge/high-frequency state needs patches — not more workers.”
Default. Structured clone copies the value. Both sides can keep using their own object. Cost grows with size/complexity.
Move ownership of a resource (e.g. ArrayBuffer).
Zero-copy for the memory; sender’s buffer is detached
(byteLength === 0).
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.
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 nodes →
DataCloneError
|
| Many marked “serializable” Web API types | Prototype chain, getters/setters metadata, private fields not preserved as live class instances |
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.
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
.buffer is.
Surma’s measurements (and the same framing on web.dev OMT):
postMessage is
not the reason to avoid workers — huge full-state
clones every frame is.
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).
Mentally run each snippet. Click the result that matches the platform model (not what you wish were true).
const a = [1]; worker.postMessage(a); a[0] = 9; — worker
reads e.data[0]. What value?
worker.postMessage({ run: () => 1 }) — what happens?
After
worker.postMessage(u8, [u8.buffer]), main thread reads
u8.byteLength.
Editor keeps a 2 MB document object in a worker. Every keystroke posts the full document to main for render.
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.
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.