Quick answer:
JSON.parse(JSON.stringify(obj))makes a correct deep copy of plain JSON data: nested objects, arrays, strings, finite numbers, booleans, and null. It silently breaks everything else: Dates become strings,undefinedand functions vanish,NaNbecomesnull, Maps and Sets become{}, and circular references throw. For anything beyond plain data, usestructuredClone().
A form saves fine for months. Then a user edits a record created before a schema change, the createdAt field goes through a "deep copy" on the way to the diff logic, and copy.createdAt.getTime() throws because createdAt is now a string. Nobody touched that code. The clone did it, and the clone was JSON.parse(JSON.stringify()).
Use JSON Compare to inspect added, removed, and changed values in two non-sensitive sample documents.
The Idiom and Why Everyone Uses It
The trick is one line, needs no import, and works in every runtime that has JSON:
const original = { user: { name: "Priya", tags: ["admin", "ops"] } };
const copy = JSON.parse(JSON.stringify(original));
copy.user.tags.push("billing");
original.user.tags; // ["admin", "ops"] (untouched)
It works because JSON.stringify walks the whole tree and writes text, and JSON.parse builds fresh objects from that text. There is no shared reference anywhere. The problem is that the text in the middle is JSON, and JSON can describe exactly six kinds of value. Anything that is not one of those six gets coerced, dropped, or rejected, with no warning.
What JSON.parse(JSON.stringify()) Loses
Run this once and keep the output somewhere you will see it again:
const src = {
when: new Date("2026-04-05T09:30:00Z"),
missing: undefined,
fn: () => 1,
ratio: NaN,
big: Infinity,
lookup: new Map([["a", 1]]),
ids: new Set([1, 2]),
pattern: /^\d+$/g,
list: [undefined, NaN],
};
JSON.parse(JSON.stringify(src));
// {
// when: "2026-04-05T09:30:00.000Z", // string, not Date
// ratio: null,
// big: null,
// lookup: {},
// ids: {},
// pattern: {},
// list: [null, null]
// }
// "missing" and "fn" are gone entirely
Three of those are the ones that cause production bugs. Dates come back as strings, so any later .getTime() or .toLocaleDateString() throws; our JSON date format guide explains why there is no date type to begin with. undefined keys disappear, so a check like "email" in copy changes answer. And NaN becomes null, which is a different thing: the first says "a calculation failed", the second says "no value". The null vs missing vs empty distinction matters to whoever reads the copy next.
Comparison Table: JSON Round-Trip vs structuredClone vs lodash cloneDeep
| Value | JSON.parse(JSON.stringify()) | structuredClone() | _.cloneDeep() |
|---|---|---|---|
| Date | ISO string | Date (same instant) | Date |
| undefined property | Key removed | Kept | Kept |
| undefined in array | null | Kept | Kept |
| Function | Dropped (key removed) | Throws DataCloneError | Kept by reference |
| NaN, Infinity | null | Kept | Kept |
| Map, Set | {} (empty object) | Map, Set | Map, Set |
| RegExp | {} | RegExp (lastIndex reset) | RegExp |
| Class instance | Plain object | Plain object | Instance (prototype kept) |
| Getter property | Evaluated to value | Evaluated to value | Evaluated to value |
| Symbol key | Dropped | Dropped | Kept |
| BigInt | Throws TypeError | Kept | Kept |
| Circular reference | Throws TypeError | Kept | Kept |
| Typed array, ArrayBuffer | Object with index keys | Kept | Kept |
| DOM node | {} | Throws DataCloneError | {} |
Two cells need explaining. The class-instance row: all three copy the own properties, but only lodash reattaches the prototype, so copy instanceof Order is true only with cloneDeep. And the function row: structuredClone refuses with a DataCloneError whose message ends in could not be cloned, the JSON round-trip quietly deletes the key, and lodash copies the function reference as-is.
structuredClone: The Built-In Replacement
structuredClone() is the same algorithm browsers have long used for postMessage and IndexedDB, exposed as a global. It ships in Node 17 and later and every current browser, and it handles the cases the JSON trick does not:
const src = { when: new Date(), lookup: new Map([["a", 1]]), ratio: NaN };
src.self = src; // circular
const copy = structuredClone(src);
copy.when instanceof Date; // true
copy.lookup.get("a"); // 1
Number.isNaN(copy.ratio); // true
copy.self === copy; // true (cycle preserved, points at the copy)
Its two hard limits:
structuredClone({ fn: () => 1 });
// DOMException [DataCloneError]: () => 1 could not be cloned.
class Order { total() { return 42; } }
const copy = structuredClone(new Order());
copy.total(); // TypeError: copy.total is not a function (prototype lost)
The function case is loud, which is better than silent. The class case is quiet, so if you clone instances of your own classes, either reattach the prototype with Object.setPrototypeOf(copy, Order.prototype) or give the class its own clone() method. See structuredClone on MDN for the full list of supported types.
lodash cloneDeep: When You Need Prototypes
_.cloneDeep predates structuredClone and is still the most forgiving option. It keeps prototypes, copies Maps, Sets, Dates, RegExps and typed arrays, tolerates cycles, and keeps functions by reference instead of throwing. What it gives back for things it cannot clone, such as DOM nodes or WeakMaps, is an empty object. The cost is a dependency and, for very large trees, more work than the native path. If your project already imports lodash, it is a fine default. If it does not, reach for structuredClone first.
How to Choose in Ten Seconds
Ask three questions about the value you are about to copy:
- Did it come from JSON.parse, and will it go back out as JSON? Then the JSON round-trip is lossless by definition, and it is the simplest option.
- Does it hold Dates, Maps, Sets, typed arrays, or cycles? Then
structuredClone. It is native, strict about what it cannot copy, and keeps all of those. - Does it hold class instances or functions you need to keep? Then
_.cloneDeep, or aclone()method on the class so the class owns the rules.
A quick way to find out which bucket your data is in: run JSON.stringify on it and paste the result into a formatter. If every value you care about is still there with the type you expect, bucket one. If you see ISO strings where Dates should be, empty objects where Maps should be, or a TypeError instead of output, you have your answer.
Spread and Object.assign Are Not Deep
This one still catches people. { ...obj } copies the top-level keys and nothing more:
const original = { profile: { city: "Pune" } };
const shallow = { ...original };
shallow.profile.city = "Nagpur";
original.profile.city; // "Nagpur" (shared reference)
Shallow copies are the right tool when you only change top-level fields, which is common in reducers and React state updates. The bug appears when a "just update one nested thing" change lands later and nobody re-reads the copy line.
When the JSON Round-Trip Is the Right Choice
It is not always wrong. Use it on purpose when:
- The data came from
JSON.parsein the first place, such as an API response or a config file. It cannot contain anything JSON cannot represent, so nothing is lost. - You want the normalization: stripping
undefined, functions, and class baggage before sending data over the wire is exactly whatJSON.stringifydoes. Running the output through JSON Prettifier is a quick way to see what the wire format will actually contain. - You are targeting an old runtime without
structuredCloneand do not want a dependency.
In those cases, write a tiny named helper such as cloneJson(value) so the next reader knows the loss is intentional, and so the call site is searchable when someone later stores a Date in it.
Gotchas Beyond the Table
- Key order and numeric keys. Both the JSON round-trip and
structuredClonekeep insertion order, but JavaScript always lists integer-like keys first regardless of method, so{ b: 1, 2: 1 }iterates as2, bin both the original and the copy. - Sparse arrays.
[1, , 3]becomes[1, null, 3]after the JSON round-trip; the hole is gone. - Precision. Numbers survive the round-trip unchanged because they are already doubles, but
-0becomes0, and anyBigIntthrowsTypeError: Do not know how to serialize a BigInt. Our JSON number precision guide covers the BigInt workarounds. - toJSON hooks fire. If a nested object defines
toJSON(), the copy contains whatever it returns, not the object. That is how Dates become strings, and it applies to your own classes too. This is the same hook used to fix circular structure errors, so a class that uses it for one purpose will also reshape its clones. - Cost of the text. The round-trip builds a full string in memory. For a multi-megabyte state tree that is a real pause, and
structuredCloneskips the text entirely.
If you want the longer story on what JSON.stringify does with each value type, including the replacer and reviver arguments, the JSON.parse and JSON.stringify guide walks through it with examples.