Skip to content
JavaScript

JSON.parse(JSON.stringify()) for Deep Clone: When It Works and When It Lies

·7 min read
JSON.parse(JSON.stringify()) for Deep Clone: When It Works and When It Lies - JavaScript guide from JSON Prettifier

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, undefined and functions vanish, NaN becomes null, Maps and Sets become {}, and circular references throw. For anything beyond plain data, use structuredClone().

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

ValueJSON.parse(JSON.stringify())structuredClone()_.cloneDeep()
DateISO stringDate (same instant)Date
undefined propertyKey removedKeptKept
undefined in arraynullKeptKept
FunctionDropped (key removed)Throws DataCloneErrorKept by reference
NaN, InfinitynullKeptKept
Map, Set{} (empty object)Map, SetMap, Set
RegExp{}RegExp (lastIndex reset)RegExp
Class instancePlain objectPlain objectInstance (prototype kept)
Getter propertyEvaluated to valueEvaluated to valueEvaluated to value
Symbol keyDroppedDroppedKept
BigIntThrows TypeErrorKeptKept
Circular referenceThrows TypeErrorKeptKept
Typed array, ArrayBufferObject with index keysKeptKept
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:

  1. 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.
  2. 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.
  3. Does it hold class instances or functions you need to keep? Then _.cloneDeep, or a clone() 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.parse in 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 what JSON.stringify does. 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 structuredClone and 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 structuredClone keep insertion order, but JavaScript always lists integer-like keys first regardless of method, so { b: 1, 2: 1 } iterates as 2, b in 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 -0 becomes 0, and any BigInt throws TypeError: 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 structuredClone skips 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.

Frequently Asked Questions

Is JSON.parse(JSON.stringify(obj)) a deep copy?
Yes, for plain data: nested objects, arrays, strings, finite numbers, booleans, and null come back as fully independent copies. It is not a faithful copy for anything else. Dates become strings, undefined and functions disappear, NaN becomes null, Maps and Sets become empty objects, and circular references throw.
Should I use structuredClone instead of JSON.parse(JSON.stringify())?
Usually, yes. structuredClone is built into Node 17+ and all current browsers, keeps Dates, Maps, Sets, RegExps, typed arrays, undefined, NaN, and circular references, and needs no library. Its limits are that it throws on functions and DOM nodes and turns class instances into plain objects.
Why did my Date turn into a string after cloning?
JSON has no date type, so JSON.stringify calls Date.prototype.toJSON and writes an ISO 8601 string such as 2026-04-05T09:30:00.000Z. JSON.parse has no idea that string was a date and leaves it as a string. Use structuredClone, or parse the field back with new Date() after cloning.
Does the spread operator make a deep copy?
No. { ...obj } and Object.assign({}, obj) copy one level only. Nested objects and arrays inside the copy are still the same references as in the original, so changing copy.address.city also changes the original.
Which deep clone method is fastest?
It depends on the shape of the data and the engine. For small plain objects the JSON round-trip is often competitive because the engines optimize it heavily, while structuredClone and lodash cloneDeep tend to win on larger or richer structures. Measure with your own data before choosing on speed alone; correctness differences matter more.

See What Survives the Round-Trip

Paste the output of JSON.stringify and format it to spot missing keys and stringified dates. Everything runs locally.

Format My JSON