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.
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
- Reproduce without a worker.
structuredClone(payload)on the main thread runs the identical algorithm and throws the identicalDOMException, synchronously, with no message queue in the way. Every diagnosis below is faster in the console than in a worker. const { format, ...rest } = payloadremoves the offending property while keeping the type checker involved:ChartMessagehas noformat, so a future contributor who re-adds one gets a compile error rather than a runtimeDataCloneError.formatIdreplaces 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.capturedAtandlegendstay as they are.Date,Map,Set,RegExp,BigInt,Blob,FileandImageBitmapall clone with full fidelity, which is precisely where structured clone beats a JSON round trip: no ISO-string reparsing, noMapflattened into an array of pairs.[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 hasbyteLength === 0. That ownership handoff is covered end to end in How to Pass Large Arrays Without Blocking the UI.
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.
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.
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
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 |