Structured Clone vs JSON Parse Stringify for Deep Copying State
Every React or Vue developer who has ever written setState(JSON.parse(JSON.stringify(prev))) has quietly shipped a bug waiting to happen. That one-liner has been the default deep copy idiom in JavaScript for over a decade, and it breaks on Date, Map, Set, RegExp, undefined, BigInt, circular references, and any class instance you care about. structuredClone(), shipped as a global in Node.js 17 and all evergreen browsers by mid-2022, handles almost all of those cases in a single function call. The question of structuredclone vs json parse deep copy is not really about performance — it is about which bugs you want to own.
What JSON round-tripping actually does to your state
The JSON.parse(JSON.stringify(value)) trick works by serializing an object to a string, then parsing it back. That round-trip is governed by the rules in ECMA-262 §25.5.2, and those rules are ruthless about what survives. Anything that is not a plain JSON value — string, number, boolean, null, array, plain object — gets coerced, dropped, or replaced with null.
Here is the damage in one snippet:
const state = {
id: 1n, // BigInt
created: new Date('2026-04-11'),
tags: new Set(['draft', 'seo']),
meta: new Map([['views', 42]]),
pattern: /^post-/i,
owner: undefined,
handler: () => 'click',
image: new Uint8Array([1, 2, 3]),
};
const copy = JSON.parse(JSON.stringify(state));
// TypeError: Do not know how to serialize a BigInt
Remove the BigInt and the call succeeds, but the result is a quiet disaster. created becomes the ISO string "2026-04-11T00:00:00.000Z". tags becomes an empty object {}. meta becomes {}. pattern becomes {}. owner and handler disappear from the output entirely. image becomes {"0":1,"1":2,"2":3}, losing its typed-array identity. None of this throws. You get a “deep copy” that silently strips type information, and the bug surfaces three screens later when something calls state.created.getTime() on what is now a string.
The reason this survived so long is that early React reducers and Redux stores held plain JSON-shaped data by convention. The idiom worked because the data was already JSON. The moment a designer adds a date picker backed by a Date object, or a backend returns BigInt IDs (which the fetch response’s .json() still cannot parse natively), the idiom breaks and the ensuing bug report blames the component, not the copy.
How structuredClone actually works
Unlike JSON.stringify, structuredClone() is not a JavaScript-level function that produces a string. It is a direct binding to the structured clone algorithm defined in the HTML Standard. You can read the full algorithm in WHATWG HTML §2.7.3 StructuredSerialize / StructuredDeserialize. The same algorithm already powered postMessage, IndexedDB, and the Cache API since 2011 — the 2022 addition was simply exposing it as a synchronous global.
The algorithm walks the object graph and emits a typed representation that includes a memo table keyed on each visited object. That memo table is why it handles cycles: when the walker encounters an object it has already serialized, it emits a back-reference instead of recursing forever. It is also why it preserves shared identity — if two fields point at the same inner object, the cloned graph will also have two fields pointing at a single cloned inner object, not two independent copies.
const inner = { count: 0 };
const state = { a: inner, b: inner };
const jsonCopy = JSON.parse(JSON.stringify(state));
jsonCopy.a === jsonCopy.b; // false — shared identity lost
const structCopy = structuredClone(state);
structCopy.a === structCopy.b; // true — shared identity preserved
const cyclic = { name: 'node' };
cyclic.self = cyclic;
JSON.stringify(cyclic); // TypeError: circular structure
structuredClone(cyclic); // works, self-reference preserved
Per the spec, the algorithm knows how to serialize a long list of platform types: Boolean, String, Number, BigInt, Date, RegExp, Blob, File, FileList, ArrayBuffer, ArrayBufferView (all typed arrays, including Uint8Array and Float64Array), ImageData, ImageBitmap, Map, Set, Error subtypes, and DOM types like DOMException, DOMMatrix, DOMPoint, and DOMRect. The MDN page on the structured clone algorithm keeps an up-to-date list if you need to check a specific type.
The things structuredClone still cannot clone
It is not magic. Three categories still fail, and knowing them matters if you use structuredClone as a blanket replacement.
Functions throw. Any property whose value is a function — including methods, arrow functions, and getters — causes structuredClone to throw a DataCloneError. The specification is explicit: closures cannot be serialized because their captured scope cannot be replayed. If your state object has a handler field, you either drop it before cloning or accept that the clone is a data-only projection.
Class instances lose their prototype. A new User(...) becomes a plain object with the same own properties after cloning. The prototype chain is replaced by Object.prototype. This is subtle because the clone works — it just does not pass instanceof checks afterward. If you are cloning a Redux-style state tree this is fine; if you are cloning a Mongoose document or a class-based store you are going to see behavior changes.
DOM nodes, Error stack traces, WeakMap, WeakSet, Symbols, and Promises throw. Symbols specifically throw because two symbols created from Symbol('x') are distinct by identity and the clone algorithm has no way to preserve that across a serialization boundary. Promises throw because cloning them would need to replay the microtask queue.
try {
structuredClone({ handler: () => 42 });
} catch (e) {
// DOMException: could not be cloned.
console.log(e.name); // DataCloneError
}
class User { constructor(name) { this.name = name; } greet() { return `hi ${this.name}`; } }
const u = new User('ada');
const copy = structuredClone(u);
copy instanceof User; // false
copy.greet; // undefined (method was on the prototype)
There is also a transfer option that takes an array of transferable objects — ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream, WritableStream, TransformStream. Transferring an ArrayBuffer detaches the original and hands ownership of the underlying memory to the clone with zero copying. This is the only way to move a large binary payload across a structured-clone boundary without doubling your memory footprint, and it is the reason postMessage to a Worker accepts the same option.
Performance: the benchmarks are more nuanced than Twitter says
A common piece of folklore is that JSON.parse(JSON.stringify(x)) is faster than structuredClone. That was true in 2022, became mixed in 2023, and in current V8 and SpiderMonkey builds the answer depends entirely on what you are cloning.

For small, flat, JSON-shaped objects — a form state with ten string fields — JSON round-tripping still wins by roughly 2-4x in V8. The JSON fast path in V8 is exceptionally well-tuned; see the V8 blog post on faster JSON.stringify for how the team turned it into a specialized bytecode path. For small objects, the serialization format is simpler than the structured clone memo table, and there is no prototype walk.
For large objects with many numeric keys, typed arrays, or deep nesting, the story inverts. Structured clone does not pay the cost of integer-to-string conversion that JSON does on every array index, and typed arrays are copied as raw buffer blits rather than per-element number parsing. A Float64Array(100_000) cloned via structuredClone is dramatically faster than the JSON round trip, which has to format each number as a decimal string and then parse it back.
For object graphs with shared references, structuredClone is unconditionally faster because it walks each unique object once and emits back-references, while JSON has to re-serialize the same subtree every time it appears. If your Redux state has a normalized shape where many slices point at the same user object, JSON duplicates every copy.
The practical takeaway: do not switch a hot path based on folklore. Benchmark with performance.now() against your actual state shape. And remember that in most React apps, the deep copy is not the bottleneck — a single JSON.parse on a 5 KB state tree runs in well under a millisecond, so the correctness of the clone matters far more than the microseconds.
When to reach for each approach
Use structuredClone as your default for any state object that might grow beyond pure JSON. This covers essentially all modern application state: a Zustand store with Map-backed caches, a form library that holds Date values, an image editor holding ImageData, a Web Worker boundary where you want zero-copy transfers via the transfer option.
Use JSON.parse(JSON.stringify(...)) only when you want the JSON coercion as part of the operation — for example, when preparing a payload to send to an API that expects a JSON body, or when normalizing data that came from a mixed source into a known-flat shape. In those cases the lossiness is the feature, not a bug. It is a serialization step dressed up as a copy.
Do not reach for Lodash’s cloneDeep unless you need things structuredClone refuses to handle — specifically, preserving class prototypes or cloning function properties by reference. cloneDeep carries a ~15 KB gzipped footprint that most apps do not need once a native global does 95% of the job. If you are on Node.js 17+ or any browser released after 2022, structuredClone is already in the runtime with zero bundle cost.
A drop-in replacement pattern
If you are migrating an existing codebase, a conservative refactor wraps the call so you can fall back in environments that lack it. This is only needed for legacy React Native Hermes builds older than 0.72 and the very oldest WebViews — every modern target ships it natively.
export function deepCopy(value) {
if (typeof structuredClone === 'function') {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value));
}
A better migration is to grep for JSON.parse(JSON.stringify across your repo and replace each call site individually, because each site is a decision: does this code need lossy JSON normalization, or does it need a real clone? You will usually find that half the call sites were written by someone who wanted structuredClone before it existed, and the other half were working around a bug in how state was typed.
Edge cases that trip production code
Three edge cases consistently surface in bug reports. Knowing about them in advance saves hours.
Proxy objects. structuredClone walks the target of a Proxy, not the proxy handlers. If you are using Immer or Vue’s reactive system, the clone strips the proxy layer and returns the raw underlying data. That is usually what you want, but it means passing a Vue ref or an Immer draft through structuredClone detaches it from the reactivity system. Unwrap first with toRaw or current and clone second.
Getters and setters. Property descriptors are not preserved. An accessor property becomes a plain data property holding the getter’s last-evaluated value. If your state relies on lazy computed properties, the cloned version materializes them eagerly.
Non-enumerable properties. They are cloned, but property descriptors are normalized — everything in the clone is {writable: true, enumerable: true, configurable: true}. If you were using Object.defineProperty to hide internal fields, the clone exposes them.
For a full enumeration of what is and is not preserved, the MDN structuredClone reference has a table that matches the HTML spec. Bookmark it; the list of supported types grows quietly each year as new platform APIs get a [Serializable] annotation in their IDL.

The one-line summary for anyone reviewing code: JSON.parse(JSON.stringify(x)) is a JSON round-trip that happens to copy data. structuredClone(x) is a deep copy that knows what JavaScript objects actually look like. If your state is richer than a JSON document — and almost all application state is — the native function is the only one of the two that deserves the name “deep copy.” Reach for it first, benchmark only if a profile says you have to, and keep JSON round-tripping for the cases where you genuinely want the lossy coercion it provides.
