JavaScript · Modules, workers & advanced
`structuredClone` & deep clone
⚡ TL;DR —
structuredClone()exposes the browser's structured clone algorithm (the onepostMessagehas always used) as a synchronous call: it copies cycles,Map,Set,Date, typed arrays and shared references correctly — but it drops prototypes, functions and symbols, so it deep-clones data, not objects.
🧠 Mental model
Structured cloning was never designed as a JS convenience. It's the serialisation format for crossing a realm boundary — worker postMessage, IndexedDB, history.pushState, BroadcastChannel. All of those needed a way to turn a live object graph into something that could survive a hop into another JS heap. structuredClone() (2022) simply lets you invoke that machinery without actually going anywhere.
That origin explains every single one of its limitations. Ask yourself: "could this thing exist meaningfully in a different JS realm with no shared code?"
- A
Mapof dates? Yes — pure data. Cloned perfectly. - A closure? No — its scope lives in this heap.
DataCloneError. - A
class Userinstance? The data can travel; the class can't. So the data arrives, and the prototype doesn't. You get a plain object with the right fields and no methods.
Once you hold that frame, you stop being surprised.
⚙️ How it actually works
The algorithm walks the graph depth-first with a memory map of already-visited references. That map buys you two things people credit to magic:
Cycles work. a.self = a clones fine. JSON throws Converting circular structure to JSON.
Shared references stay shared. If obj.x and obj.y are the same array, then clone.x === clone.y. This is the detail almost nobody mentions and it is the real difference between a clone and a copy — the shape of the graph is preserved, not just the values.
What crosses, and what doesn't:
| Cloned faithfully | Silently changed | Throws DataCloneError |
|---|---|---|
primitives, BigInt |
class instances → plain object | functions |
Object, Array, cycles |
getters → flattened to data props | most DOM nodes |
Map, Set, Date, RegExp |
non-enumerable props → dropped | Symbol values |
ArrayBuffer, typed arrays, DataView |
symbol-keyed props → dropped | WeakMap / WeakSet |
Blob, File, ImageData, Error |
property descriptors → all writable/enumerable |
Proxy (throws if target does) |
Note the middle column carefully: those failures are silent. A getter is invoked during the clone and its return value is baked in as a static property. Your lazily-computed field becomes a stale snapshot, and nothing warns you.
Transfer, not copy. structuredClone(obj, { transfer: [obj.buffer] }) moves an ArrayBuffer by pointer instead of memcpy-ing it: O(1) instead of O(n), and the source buffer is left detached (byteLength === 0). That's the same option postMessage takes, and it's how you move a 50MB image out of a worker without a copy.
💻 Code
The JSON round-trip everybody still writes, and everything it quietly destroys:
const state = {
when: new Date('2020-01-01'),
tags: new Set(['a']),
index: new Map([[1, 'one']]),
missing: undefined,
nope: NaN,
big: 10n,
fn() {},
};
JSON.parse(JSON.stringify(state));
// ❌ when → "2020-01-01T00:00:00.000Z" (string! silently)
// ❌ tags → {} (Set has no JSON form)
// ❌ index → {} (same)
// ❌ missing→ key vanishes entirely
// ❌ nope → null (NaN and ±Infinity both become null)
// ❌ big → TypeError: Do not know how to serialize a BigInt
// ❌ fn → key vanishes
// …and `toJSON()` on any nested object hijacks its own serialisation behind your back.
structuredClone(state);
// ✅ Date, Set, Map, undefined, NaN, BigInt all survive intact.
// ❌ but `fn` throws DataCloneError — remove functions first.
The prototype trap, which is the one that bites in production:
class User {
constructor(name) { this.name = name; }
greet() { return `hi ${this.name}`; }
}
const clone = structuredClone(new User('ada'));
clone.name; // 'ada' ← data survived
clone instanceof User; // false ← ❌
clone.greet(); // TypeError: clone.greet is not a function
Object.getPrototypeOf(clone) === Object.prototype; // true
// ✅ If you own the class, clone the data and rehydrate deliberately:
const revived = Object.assign(Object.create(User.prototype), structuredClone({ ...user }));
// ✅ Or better: give the class an explicit boundary.
class User { toJSON() { … } static from(data) { return new User(data.name); } }
⚖️ Trade-offs
- It is not automatically faster than
JSON.parse(JSON.stringify(x)). This surprises people. For a large, plain, JSON-shaped object the JSON round-trip is a tight, heavily-optimised native path and frequently wins;structuredClonepays for graph bookkeeping and type dispatch on every node. Choose it for correctness, not speed, and benchmark before claiming otherwise. - It's synchronous and it blocks. Cloning a 100k-node tree janks the main thread exactly like any other long task. Being a browser API doesn't make it free or off-thread.
- Reach for
lodash.cloneDeepwhen you need prototypes. It preserves class instances, copies functions by reference instead of throwing, and neverDataCloneErrors — at the cost of a dependency and a hand-written approximation of the same algorithm. That's a real trade, not an obviously wrong one. - Don't deep-clone as a default. Most "I need a deep clone" moments are really "I mutated shared state and got caught". Structural sharing (spread the one level you're changing) or an immutable helper is cheaper and expresses intent. A deep clone of app state on every action is a performance smell.
💣 Gotchas interviewers probe
- "Does
structuredClonepreserve the class?" No. Prototypes are dropped — instances arrive as plain objects with no methods andinstanceofreturnsfalse. This is the single most common miss. - Getters are executed and flattened. A computed property becomes a frozen value. So do non-enumerable and symbol-keyed props — except those are just dropped, with no error.
- Shared references stay shared, cycles survive. Both JSON and a naive hand-rolled recursive clone get this wrong (JSON throws; the naive version infinite-loops until it blows the stack).
Symbolas a value throws; symbol as a key is silently ignored. Two different behaviours for the same type — an easy trip-up.transferdetaches the source. After transferring anArrayBuffer, the original isbyteLength === 0and every read from it throws. People transfer, then try to reuse the buffer.Object.freezeisn't cloned. The clone is mutable regardless of the original's frozen/sealed state — the algorithm copies data, not integrity levels.DataCloneErroris aDOMException, not aTypeError. If you're catching narrowly, you'll miss it.- Availability: browsers from ~2022, Node from 17. If you support older runtimes it needs a fallback, and there's no faithful polyfill (you cannot recreate
Blobtransfer semantics in userland).
🎯 Say this in the interview
"
structuredCloneis the structured clone algorithm — the same thingpostMessageand IndexedDB use — exposed as a direct call. That framing predicts its behaviour: anything that could meaningfully exist in another JS realm survives, soMap,Set,Date, typed arrays, cycles and even shared references all clone correctly. Anything tied to this realm doesn't: functions throwDataCloneError, and — the one people miss — prototypes are dropped, so a class instance comes back as a plain object with no methods andinstanceoffalse. It's strictly more correct thanJSON.parse(JSON.stringify()), which silently turns Dates into strings,NaNintonull, and dropsundefined. But I wouldn't claim it's faster — for plain JSON-shaped data the JSON round-trip is often quicker, andstructuredCloneis synchronous, so cloning a big graph blocks the main thread. Mostly, if I reach for a deep clone at all, I ask first whether I should be sharing structure instead of copying it."
🔗 Go deeper
- MDN —
structuredClone()— the API, plus thetransferoption. - MDN — The structured clone algorithm — the authoritative list of what clones and what throws.
- web.dev — Deep-copying in JavaScript using
structuredClone— good on why the JSON hack persisted for so long. - HTML spec — StructuredSerialize — where the getter-flattening and memory-map behaviour is actually defined.