Step-by-Step Guide to the Structured Clone Algorithm

Every postMessage call runs your payload through one algorithm that decides — silently, in both directions — what arrives on the other thread and what it costs to get there.

This page takes the serialization mechanics behind Message Passing Strategies, part of the Web Workers Architecture & Communication reference, and narrows them to a single task: getting one real payload across the boundary intact, then finding out whether the copy is cheap enough to keep.

What the Algorithm Actually Does to Your Object

Structured clone is not JSON.stringify with more types. It is a graph walk that produces an intermediate serialized form and then rebuilds a fresh object graph in the destination realm’s heap. Three consequences follow from that shape, and most confusion at the boundary comes from missing one of them.

It walks a graph, not a tree. The algorithm keeps a memory map of every object it has already visited. A cycle terminates instead of recursing forever, and — less well known — two properties pointing at the same object still point at the same object after cloning. Two Float32Array views over one shared ArrayBuffer arrive as two views over one buffer, not as two independent copies.

It copies values, not identity. The output is structurally equal and referentially unrelated. A class instance loses its prototype and arrives as a plain object; #private fields, which are not properties at all, simply cease to exist. Nothing throws.

It reads through getters. For an ordinary object the algorithm enumerates own enumerable string-keyed properties and performs a real [[Get]] on each. An accessor that computes, memoizes, or logs will run during serialization, and what lands on the other side is a plain data property holding whatever it returned that one time. Non-enumerable properties and symbol-keyed properties are never visited.

One object graph in, a differently-shaped one out Three panels. The left panel is the source graph on the main-thread heap: a ChartSeries class instance carrying a prototype and a hash-private field, a get stats accessor computed on read, two typed-array views A and B that both point at one ArrayBuffer, and a meta.root property whose arrow loops back to the instance, forming a cycle. The middle panel is the algorithm itself: it keeps a memory map recording every object it visits exactly once, so cycles terminate and sharing is preserved but identity is not, and it performs a real property read on every own enumerable key, so symbol-keyed and non-enumerable properties are never visited. The right panel is the reconstructed graph on the worker heap: the instance has become a plain object with its prototype and private field gone, the accessor has been flattened into a frozen data property because the getter already ran on the sender, views A and B still share one ArrayBuffer, and meta.root still loops back to the same new object. One object graph in, a differently-shaped one out structure survives · identity does not Source graph · main-thread heap StructuredSerialize Reconstructed graph · worker heap new ChartSeries(…) prototype · #private field get stats() accessor, computed on read view A view B one ArrayBuffer meta.root points back to the instance memory map every object it visits is recorded exactly once → cycles terminate → sharing preserved → identity is not a real [[Get]] on every own key symbol keys and non-enumerables are never visited { … } a plain object prototype gone · #private field gone stats: 41.8 a frozen data property — the getter already ran view A view B one ArrayBuffer — still one, still shared by both views meta.root still points at the same new object Solid outline = arrives intact · dashed outline = arrives degraded, with no exception thrown anywhere.
The graph walk is faithful about shape — cycles close, shared buffers stay shared — and silent about everything that lived in the prototype or in an accessor.

Minimal Reproducible Example

Here is a payload of the kind a charting front end actually builds — a typed message with a date, a lookup map, a sample buffer, and a formatter function attached for convenience.

// payload.ts — main-thread side
interface ChartPayload {
  seriesId: string;
  capturedAt: Date;                 // cloneable
  legend: Map<string, string>;      // cloneable
  samples: Float32Array;            // cloneable, but copied byte-for-byte
  format: (value: number) => string; // NOT cloneable — this is the whole bug
}

const payload: ChartPayload = {
  seriesId: 'cpu.load.1m',
  capturedAt: new Date(),
  legend: new Map([['p50', 'median'], ['p99', 'tail']]),
  samples: new Float32Array(1_000_000),
  format: (value) => `${value.toFixed(1)} %`,
};

worker.postMessage(payload);
// DOMException: Failed to execute 'postMessage' on 'Worker':
// (value) => `${value.toFixed(1)} %` could not be cloned.

The fix is a second type — a wire contract that contains only values the algorithm can carry — plus a transfer list so the megabyte of samples is handed over rather than duplicated:

// wire.ts — what actually crosses the boundary
interface ChartMessage {
  seriesId: string;
  capturedAt: Date;
  legend: Map<string, string>;
  samples: Float32Array;
  formatId: 'percent' | 'currency'; // an identifier the worker resolves itself
}

function toWire(payload: ChartPayload): ChartMessage {
  const { format, ...rest } = payload;   // drop the function at the type level
  return { ...rest, formatId: 'percent' };
}

const message = toWire(payload);
worker.postMessage(message, [message.samples.buffer]);
// message.samples.byteLength is now 0 on this thread — the bytes moved.

Step-by-Step Walkthrough

  1. Reproduce without a worker. structuredClone(payload) on the main thread runs the identical algorithm and throws the identical DOMException, synchronously, with no message queue in the way. Every diagnosis below is faster in the console than in a worker.
  2. const { format, ...rest } = payload removes the offending property while keeping the type checker involved: ChartMessage has no format, so a future contributor who re-adds one gets a compile error rather than a runtime DataCloneError.
  3. formatId replaces the function with data. The worker holds its own lookup table of formatters and resolves the id locally. This is the general escape hatch — behaviour cannot cross a thread boundary, so send a name for behaviour that already exists on both sides.
  4. capturedAt and legend stay as they are. Date, Map, Set, RegExp, BigInt, Blob, File and ImageBitmap all clone with full fidelity, which is precisely where structured clone beats a JSON round trip: no ISO-string reparsing, no Map flattened into an array of pairs.
  5. [message.samples.buffer] is the transfer list, and it is a separate argument for a reason — the buffer is still reachable through the message graph, so it is serialized as usual, but the algorithm is told to move its memory instead of copying it. Afterwards the sender’s view has byteLength === 0. That ownership handoff is covered end to end in How to Pass Large Arrays Without Blocking the UI.
One postMessage: what is copied and what is moved A two-lane sequence, time running left to right. On the main-thread lane, toWire builds the wire object, then postMessage with a transfer list runs the serialize step synchronously and blocks the sender for its duration, after which the call returns and the sender is free. Between the lanes sit two boxes describing the wire. The first covers the cloned fields — Date, Map and strings — which now exist on both heaps at roughly 0.7 milliseconds per megabyte each way. The second covers the transferred buffer — the ArrayBuffer itself, which exists once rather than twice because the sender's view is detached. A third box states the net effect: small fields cost two copies, the four-megabyte buffer costs one, and peak memory stays flat. On the worker lane the message is queued with nothing running, then the first read of event.data triggers deserialization inside the handler, and only afterwards does the computation run on bytes that are already local. One postMessage: what is copied and what is moved time → Main thread sender Worker thread receiver toWire(payload) build the wire object postMessage(msg, [buf]) serialize runs here, synchronously the sender is blocked for its duration call returns sender free again cloned fields Date · Map · strings now on BOTH heaps ≈0.7 ms per MB, each way transferred buffer the ArrayBuffer itself exists ONCE, not twice sender view detached net effect small fields: 2 copies 4 MB buffer: 1 copy peak memory stays flat queued nothing runs yet first read of event.data deserialize happens HERE — inside your handler, and is billed as handler time compute bytes already local Serialization is charged to the sender's frame; deserialization hides inside the receiver's handler.
The transfer list does not skip serialization — the buffer is still walked as part of the graph. What changes is the ending: its memory is handed over instead of duplicated.

Which Types Survive the Boundary

The algorithm enforces a fixed matrix. Anything in the right-hand column has to be transformed before it reaches postMessage.

Category Clones with full fidelity Throws DataCloneError
Primitives & containers string, number, boolean, null, undefined, BigInt, Array, plain Object Symbol, Function, Proxy
Collections & values Map, Set, Date, RegExp, Error (partial) WeakMap, WeakSet, WeakRef
Binary & media ArrayBuffer, DataView, all TypedArrays, Blob, File, FileList, ImageData, ImageBitmap detached ArrayBuffer, SharedArrayBuffer outside a cross-origin-isolated page
Platform objects MessagePort, ReadableStream, OffscreenCanvas (transfer only) DOM nodes, Window, Document, Event, most other DOM interfaces

Two entries deserve their own line. Error is cloneable in current engines but keeps only name, message and — where the engine implements it — stack and cause; a custom subclass arrives as a base Error with every extra own property stripped, which is why cross-thread failures need structured error serialization rather than a raw throw. And a class instance is not in the right-hand column: it clones happily, just not as an instance.

Three ways to move one megabyte, compared trait by trait A seven-row matrix with three columns: a JSON round trip via stringify and parse, structured clone as used by postMessage and structuredClone, and the transfer list passed as the second postMessage argument. Dates survive only structured clone; JSON reduces them to an ISO string. Map and Set survive only structured clone; JSON empties them to a bare object. Cycles and shared references survive only structured clone; JSON throws a TypeError. TypedArray bytes are copied byte-for-byte by structured clone, expanded into a much larger number list by JSON, and moved with zero copying by the transfer list. Class prototypes are lost by every mechanism. Functions and symbols are dropped without error by JSON and throw a DataCloneError under structured clone. On cost per megabyte, JSON costs about fifteen milliseconds plus a string intermediate, structured clone costs 0.7 to 20 milliseconds each way, and a transfer costs under 0.05 milliseconds regardless of size. The transfer list applies only to binary buffers and a few platform objects, so it is marked not applicable on every other row. Three ways to move one megabyte kept partial lost or throws not applicable Behaviour at the boundary per 1 MB payload JSON round trip stringify → parse structured clone postMessage / structuredClone transfer list second postMessage argument Date → ISO string only a real Date object not transferable Map / Set { } — contents lost keys and values kept not transferable Cycles & shared references throws TypeError preserved exactly not transferable TypedArray bytes number list, ~3× larger byte-for-byte copy moved, zero copy Class prototype & #private lost, no error lost — a plain object not transferable Functions & symbols dropped, no error throws DataCloneError not transferable Cost per megabyte ≈15 ms + a string copy 0.7–20 ms each way <0.05 ms, flat
Only the middle column keeps cycles, Map and Date while still copying bytes — and only the right-hand column avoids paying for those bytes at all.

Diagnosing a DataCloneError in Under a Minute

The browser’s message names one culprit — usually by printing the offending function’s source — but says nothing about where it sat in a deeply nested message. This probe finds the path by cloning each subtree and descending only into the ones that fail:

// clone-probe.js — dev-only; narrows a DataCloneError to exact property paths
function findUncloneable(value, path = '$', seen = new WeakSet()) {
  try {
    structuredClone(value);
    return [];                                  // this whole subtree is fine
  } catch (err) {
    if (err?.name !== 'DataCloneError') throw err;
  }

  // A failing leaf is the culprit itself: a function, a symbol, a DOM node.
  if (value === null || typeof value !== 'object') {
    return [`${path}${typeof value}`];
  }
  if (seen.has(value)) return [];               // already reported via another edge
  seen.add(value);

  const entries = value instanceof Map ? [...value.entries()] : Object.entries(value);
  const failures = entries.flatMap(([key, child]) =>
    findUncloneable(child, `${path}.${String(key)}`, seen),
  );

  // Nothing below failed, so the container is the problem: Proxy, WeakMap, DOM node.
  return failures.length ? failures : [`${path}${value.constructor?.name ?? 'object'}`];
}

console.table(findUncloneable(payload));
// $.format — function
// $.chart.canvas — HTMLCanvasElement

Run it from the console or behind a if (import.meta.env.DEV) guard. It clones repeatedly by design, so it is a debugging instrument, not something to leave on a hot path. In DevTools, pair it with Pause on caught exceptions in the Sources panel: the probe’s own try block gives you a breakpoint standing exactly on the failing value with the live object in scope.

Narrowing a DataCloneError to one property and one fix A decision tree. The root asks whether structuredClone of the payload throws. If it does not, the payload itself is clean and the throw came from the transfer list — a detached buffer or an entry that is not a transferable type. If it does throw, run the path probe: findUncloneable clones each subtree and descends only into the ones that fail. The probe reports one of three culprits, each with its own fix. A function or symbol, reported as dollar dot format, is fixed by sending an identifier that the worker resolves from its own lookup table. A DOM node, Proxy or Weak collection, reported as dollar dot chart dot canvas, is fixed by extracting plain data or transferring an OffscreenCanvas instead. A detached ArrayBuffer, reported as dollar dot samples, is fixed by re-acquiring the bytes before resending and calling slice to keep a local copy. Every branch ends the same way: define a wire type that holds only cloneable values, then validate the reconstructed shape inside the worker, because silent degradation never throws. Narrowing a DataCloneError to one property and one fix structuredClone(payload) does it throw? no yes The payload itself is clean the throw came from the transfer list: a detached buffer, or a non-transferable Run the path probe findUncloneable() clones each subtree and descends only into the failures $.format — function send an identifier instead, and let the worker resolve it locally $.chart.canvas — DOM node extract plain data, or transfer an OffscreenCanvas in its place $.samples — detached buffer re-acquire the bytes before resending; slice() first to keep a local copy Every branch ends in the same place define a wire type that holds only cloneable values, then validate the reconstructed shape inside the worker — silent degradation never throws
The throw tells you a value failed; the probe tells you which one. Everything after that is a choice between sending a name, sending plain data, or sending ownership.

Gotchas & Edge Cases

The copy is paid twice, and only one half is visible

Serialization runs synchronously inside postMessage on the sender’s thread. Deserialization runs on the receiver — lazily in Blink, on the first read of event.data, which makes it look like part of your handler rather than part of the message. A “5 ms” handler that spends 3.5 ms reconstructing an object graph is a transfer problem, not an algorithm problem; measuring structured clone cost with performance.now() shows how to split those numbers apart.

Getters run, and they run on the sender’s thread

Because the algorithm performs a real property read, a lazily-computed accessor is invoked during serialization — inside the blocking postMessage call, at exactly the moment you are trying not to spend time. Worse, a getter that returns a fresh object each call defeats the memory map: what looked like one shared node becomes N independent copies. Materialise computed views into plain fields before sending.

A detached buffer throws on the second send

Once transferred, the sender’s ArrayBuffer is detached and byteLength reads 0. Sending it again — a retry, a queued duplicate, the same buffer listed twice in one transfer list — throws DataCloneError: ArrayBuffer at index 0 is already detached. If the sender still needs the bytes, clone first (samples.slice()) and transfer the copy, and treat any retry logic as needing fresh ownership rather than a re-send.

SharedArrayBuffer is cloned by reference, and only when isolated

SharedArrayBuffer is the one exception to copy semantics: cloning it shares the same memory rather than duplicating it, and it cannot appear in a transfer list at all. It is also unavailable — the clone throws — unless the page is cross-origin isolated, which requires the server to send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Those headers block third-party frames and subresources that do not opt in via CORS or CORP, so adopting shared memory is a deployment decision as much as a code one; postMessage vs SharedArrayBuffer works through when that cost is worth paying.

Nothing validates the reconstructed shape for you

Prototype loss, dropped symbol keys and a flattened getter all arrive without an exception. Guard the contract on entry instead of trusting it:

// chart.worker.js
self.onmessage = ({ data }) => {
  // Reading data once forces deserialization deliberately, before any timing starts.
  const { seriesId, samples, formatId } = data;

  if (typeof seriesId !== 'string' || !(samples instanceof Float32Array)) {
    self.postMessage({ type: 'ERROR', reason: 'wire contract violated', seriesId });
    return;
  }
  if (samples.byteLength === 0) {
    // A transferred buffer that arrives empty means it was already detached upstream.
    self.postMessage({ type: 'ERROR', reason: 'empty buffer — double transfer?', seriesId });
    return;
  }

  self.postMessage({ type: 'RESULT', seriesId, stats: summarise(samples, formatId) });
};

Performance Note

Performance

Structured clone costs roughly 0.7 ms per megabyte for a flat Float32Array and ten to thirty times that for a dense object graph of the same weight — the difference is per-node bookkeeping, not bytes. Both halves are paid, sender and receiver, and peak memory is about 2× the payload while both copies exist. A transfer of the same buffer costs under 0.05 ms and allocates nothing.

The rule of thumb that survives contact with real code is a ratio, not a size threshold: if the clone is more than about 20% of the work the message triggers, the payload shape is the bottleneck and no amount of algorithm tuning will show up in a profile. Below roughly 100 KB of structured data per message, clone freely — the fidelity of Map, Date and cycles is worth more than the microseconds. Above about 1 MB of binary data, transfer. In between, measure once against your own shapes rather than against a benchmark’s.

Property structuredClone / postMessage JSON.parse(JSON.stringify()) Transfer list
1 MB flat Float32Array ~0.7 ms each way ~15 ms + string intermediate <0.05 ms, size-independent
1 MB dense object graph ~7–20 ms each way ~20–40 ms not applicable
Cycles preserved throws TypeError not applicable
Date / Map / Set preserved lost or flattened not applicable
Peak memory ~2× payload ~3× payload (string intermediate) 1× — ownership moves
Sender left with the data yes yes no — view is detached
Cost per megabyte, by payload shape — the shape matters more than the size A grouped bar chart measuring milliseconds per megabyte in one direction, with a dashed rule at 16.7 milliseconds marking a single 60 frames-per-second frame and a flat reference line just above zero for a transfer. For one megabyte of flat Float32Array, structured clone takes about 0.7 milliseconds while a JSON round trip takes about 15. For one megabyte of nested object graph, structured clone takes about 14 milliseconds and a JSON round trip about 30. For one megabyte of Map-heavy structure, structured clone takes about 9 milliseconds while a JSON round trip takes about 26 and is lossy, because Map entries do not survive it at all. A transfer of the same megabyte costs under 0.05 milliseconds in every case and does not vary with size. Cost per megabyte, by payload shape structured clone JSON.parse(JSON.stringify()) transfer — <0.05 ms, flat 16.7 ms frame budget ms per MB, one direction 0 10 20 30 16.7 ms — one 60 fps frame 0.7 ms 15 ms 14 ms 30 ms 9 ms 26 ms · lossy 1 MB flat Float32Array ≈262 k floats, one contiguous block 1 MB nested object graph ≈40 k small objects, deeply nested 1 MB Map-heavy structure ≈20 k Map entries with Date values Identical byte counts, twenty-fold cost spread: the bill is per node visited, not per byte moved. And the chart shows one direction only — a clone is paid again on the receiver.
The dashed rule is the whole argument: a flat buffer clones inside a frame, the same weight of small objects does not, and the transfer line barely leaves the axis.

Frequently Asked Questions

Which values throw DataCloneError, and which are silently degraded instead?
Only a short list actually throws: functions, Symbol values, Proxy objects, WeakMap/WeakSet/WeakRef, DOM nodes, Window, and an ArrayBuffer that has already been detached. Everything else that “doesn’t work” is degradation, not an error — a class instance arrives as a plain object with its prototype and private (#) fields gone, getters are invoked and frozen into data properties, non-enumerable and symbol-keyed properties disappear, and an Error keeps only name, message and (where supported) stack and cause. The throw is the easy case; the silent degradation is what reaches production.
When should I transfer a buffer instead of letting it be cloned?
Use the ratio, not the raw size. Structured clone runs at roughly 0.7 ms per megabyte for a flat Float32Array and ten to thirty times that for a dense object graph of the same weight, and you pay it twice — serialize on the sender, deserialize on the receiver. Once the copy is a visible fraction of a 16.7 ms frame, or roughly above 1 MB of binary data per message, move the ArrayBuffer into the transfer list: the handoff is a pointer swap that costs under 0.05 ms regardless of size. The trade is that the sender’s view is detached, so anything that still needs the bytes locally must clone deliberately first.

See also