How it works
structuredClone(value) is StructuredSerializeInternal followed by StructuredDeserialize, two separate passes. Serialization turns the graph into an intermediate Record tree, with a memory map from each source object to its Record. A second visit to the same object returns the existing Record, which is how cycles and shared references survive. Deserialization then builds new objects from those Records.
For an ordinary object, the serialize step does, per the HTML spec:
- It takes
EnumerableOwnProperties(value, key), which is why symbol keys and non-enumerable properties are dropped.
- For each key it checks
HasOwnProperty, then reads value.[[Get]](key, value). Reading through [[Get]] is what runs a getter and stores its result, and why the clone has data properties.
- It never reads
[[Prototype]], so deserialization creates a plain Object.
Error objects take their own branch: the serialized name is kept only if it is one of the seven built-in error names (otherwise it becomes "Error"), and message is kept only when it is an own data property. Custom fields are not part of the specified record.
let reads = 0;
const source = {
get stamp() { reads++; return 'now'; },
[Symbol('hidden')]: 1,
};
Object.defineProperty(source, 'secret', { value: 2, enumerable: false });
const copy = structuredClone(source);
console.log(reads, Object.getOwnPropertyDescriptor(copy, 'stamp')); // → 1 { value: 'now', writable: true, enumerable: true, configurable: true }
console.log(Reflect.ownKeys(copy)); // → [ 'stamp' ]
class ValidationError extends Error {
name = 'ValidationError';
field = 'email';
}
const error = structuredClone(new ValidationError('bad'));
console.log(error.name, error.message, error.field, error instanceof ValidationError); // → Error bad undefined false
Edge cases
- Getters run exactly once, and may throw. An error thrown by a getter propagates out of
structuredClone. Clone untrusted objects knowing that.
- Holes in sparse arrays are preserved, unlike with
Array.from.
transfer moves instead of copies. structuredClone(buffer, { transfer: [buffer] }) detaches the original ArrayBuffer (byteLength becomes 0) and avoids copying the bytes. It is the same mechanism postMessage uses.
- Shared references stay shared. Two properties pointing to one object still point to one object in the clone.
const buffer = new ArrayBuffer(8);
const moved = structuredClone(buffer, { transfer: [buffer] });
console.log(buffer.byteLength, moved.byteLength, buffer.detached); // → 0 8 true
const shared = { id: 1 };
const graph = structuredClone({ a: shared, b: shared });
console.log(graph.a === graph.b, graph.a !== shared); // → true true
console.log(structuredClone([1, , 3])); // → [ 1, <1 empty item>, 3 ]
Real-world scenario
The same algorithm backs postMessage, so the limits of structuredClone are the limits of sending data to a Worker, between windows, or through a Node MessageChannel. A class instance arrives as a plain object, and a payload with a function fails with DataCloneError at the call site, not on the receiving side:
import { once } from 'node:events';
import { MessageChannel } from 'node:worker_threads';
class Cart {
items = [{ sku: 'a1', qty: 2 }];
total() { return this.items.reduce((sum, item) => sum + item.qty, 0); }
}
const { port1, port2 } = new MessageChannel();
port1.postMessage(new Cart());
const [received] = await once(port2, 'message');
console.log(received.constructor.name, typeof received.total, received.items); // → Object undefined [ { sku: 'a1', qty: 2 } ]
try {
port1.postMessage({ onDone() {} });
} catch (error) {
console.log(error.name); // → DataCloneError
}
port1.close();
The usual fix is to send data and rebuild behaviour on the other side: send cart.items (or a toJSON-style plain snapshot) and construct new Cart(items) where it is received.
Trade-offs
structuredClone is the right default for plain, acyclic-or-cyclic data with built-in types: it is built in and handles graphs and common built-ins. It is a full copy every time, so on large state it costs time and memory proportional to the whole graph, where immutable updates with spread copy only the path from the root to the changed node and share everything else. For undo history or frequent snapshots of big state, prefer structural sharing (spread the changed path, or a library built for it) and reserve structuredClone for boundaries: crossing threads, persisting, or isolating input you don't trust. When class instances matter, write a clone() method on the class; no general-purpose cloner can know your invariants.