For a deep copy of plain data, use structuredClone(). Compared with JSON.parse(JSON.stringify(value)), it keeps Date, Map, Set, BigInt, undefined, NaN and circular references intact. It has its own limits: it throws a DataCloneError on functions, symbols and proxies, and it turns class instances into plain objects. Use JSON when the copy is going over the wire anyway.
Both approaches fail quietly on some inputs and loudly on others, so the detail worth knowing is which values each one mangles.
What does JSON.parse(JSON.stringify()) actually lose?
The JSON round trip only knows about strings, numbers, booleans, null, arrays and plain objects. Everything else gets converted, dropped or rejected, and the MDN reference for JSON.stringify documents each case.
undefined, functions and symbols disappear from objects and become null inside arrays. NaN and Infinity become null. A Date calls its own toJSON(), so it comes back as an ISO string, and nothing turns it back into a Date. Map and Set have no enumerable own properties, so both serialise to {} and your data is gone without a warning.
Two inputs throw instead. A BigInt raises a TypeError (in V8 the message is Do not know how to serialize a BigInt), and a circular reference raises TypeError: Converting circular structure to JSON. There is a subtler loss too: if two properties point at the same object, the copy gets two separate objects, so identity checks that held on the original stop holding on the clone.
const shared = { id: 1 };
const original = {
when: new Date(0),
tags: new Set(["a", "b"]),
missing: undefined,
ratio: NaN,
left: shared,
right: shared,
};
const viaJson = JSON.parse(JSON.stringify(original));
console.log(viaJson);
// { when: '1970-01-01T00:00:00.000Z', tags: {}, ratio: null,
// left: { id: 1 }, right: { id: 1 } }
console.log(viaJson.left === viaJson.right); // false
const viaClone = structuredClone(original);
console.log(viaClone.when instanceof Date); // true
console.log(viaClone.tags.has("b")); // true
console.log("missing" in viaClone); // true
console.log(Number.isNaN(viaClone.ratio)); // true
console.log(viaClone.left === viaClone.right); // true
What does structuredClone keep?
structuredClone() runs the same structured clone algorithm the browser uses for postMessage and IndexedDB. Its supported list covers primitives other than symbols, arrays, plain objects, Date, RegExp, Map, Set, ArrayBuffer, typed arrays, DataView, Blob, File and the standard Error types, among others. It also keeps cycles and shared references: an object that points at itself still points at itself in the copy.
It is Baseline widely available, and MDN’s structuredClone page dates cross-browser support to March 2022. Node.js has had it as a global since v17.0.0, according to the Node.js globals documentation, so every supported Node LTS line has it and no polyfill is needed on the server either.
It also preserves things JSON cannot express at all, such as -0, array holes and the cause of a standard error.
Why does structuredClone throw DataCloneError?
When part of the input cannot be serialised, structuredClone() throws a DOMException named DataCloneError. The message names the culprit, which is the string people end up pasting into a search box:
DataCloneError: ()=>{} could not be cloned.
DataCloneError: Symbol(x) could not be cloned.
DataCloneError: #<Object> could not be cloned.
DataCloneError: Cannot clone object of unsupported type.
Functions are the first case: a callback anywhere in the tree, including a method on an object literal, fails the whole clone. A symbol value fails the same way. Symbol keys are different, and are skipped silently.
The #<Object> message is usually a Proxy. State libraries wrap your data in proxies to track reads and writes, so cloning Vue’s reactive() state, for instance, fails even though the data underneath is plain. Unwrap it first: Vue provides toRaw() for this, and most proxy-based stores have an equivalent.
“Cannot clone object of unsupported type” means you handed it a built-in that is not on the supported list. URL, URLSearchParams and Headers all fall into this group, and DOM nodes throw too. Convert those to their string or array forms before cloning, then rebuild them on the other side.
What does structuredClone silently get wrong?
These are the losses that reach production, because nothing throws.
Class instances become plain objects. MDN is direct about it: “The prototype chain is not walked or duplicated.” A cloned User keeps its own data properties but loses its methods and fails instanceof User. Private fields (#secret) are not copied either.
Getters are flattened as well. A getter is called once during the clone and its result is stored as an ordinary property. Setters, readonly descriptors and non-enumerable properties are not carried over, so a frozen object comes back unfrozen.
Custom errors lose their identity. The algorithm only recognises the built-in error names (Error, TypeError, RangeError and the rest); anything else is reset to "Error". A class HttpError extends Error with a status field will not come back as an HttpError, and you should not count on status surviving either.
A RegExp also loses its lastIndex, which only matters if you clone a global regex halfway through an exec() loop.
If your data is class-heavy, a structural copy is the wrong tool. Give the class its own clone() method, or a static fromJSON() that rebuilds an instance from plain data, and call that instead.
structuredClone vs JSON.parse(JSON.stringify()): which should you use?
Use structuredClone() for in-memory copies: snapshotting state before a mutation, isolating a test fixture, taking a copy of a config object before a function edits it. It is built in, handles the types modern code actually uses, and fails loudly on functions rather than handing you a half-empty object.
Keep the JSON round trip for one job: data that is going to be serialised anyway. If the copy is heading to localStorage, an HTTP body or a log line, round-tripping through JSON shows you exactly what the receiver will get, Date-as-string included. Write a replacer and reviver if you need BigInt or Map support there; our post on keeping large integers exact with JSON.parse covers the reviver side.
Lodash’s cloneDeep is still around, but its behaviour differs in a way that matters. The Lodash docs say: “An empty object is returned for uncloneable values such as error objects, functions, DOM nodes, and WeakMaps.” So for a function or a DOM node, where structuredClone() throws, cloneDeep quietly substitutes {}, and it does the same to an Error that structuredClone() would have copied. We would not add Lodash to a project just for this.
The same rules apply beyond structuredClone() itself. postMessage to a Web Worker or a Node worker_threads worker uses structured cloning too, so the DataCloneError you see there has the same causes and the same fixes. Our comparison of Node worker threads and child processes goes through what that costs when you pass large payloads.
What we do in practice: structuredClone() by default, JSON only at the serialisation boundary, and an explicit clone() method on any class whose identity matters. And when only one level of an object is changing, a shallow spread of that level is enough, so we skip the deep copy entirely.
Most of the JavaScript and TypeScript we write for clients is this kind of state-handling code, where a dropped Date or an unfrozen config shows up as a production bug weeks later. If your Node.js service needs that kind of attention, our Node.js development work covers it.