How to Deep Copy JavaScript Objects: structuredClone vs JSON.parse
Last month I burned an afternoon on a bug that refused to make sense. A settings form in one of my apps was "forgetting" notification preferences for some users. No errors, no stack traces. The state went in correct and came out wrong. The culprit turned out to be one line I had copy-pasted between projects for years: JSON.parse(JSON.stringify(state)). One of those settings was a Map, and the JSON round trip turned it into an empty object. Every lookup after that returned undefined, and the calling code politely substituted a default. Silent, plausible, wrong.
If you write JavaScript, you have almost certainly used that one-liner. For a long time it was the only way to get a real deep copy without pulling in Lodash. Then structuredClone() shipped in every major browser and in Node 17, and the trade-off changed. Here is what the old trick actually costs, what the native API fixes, and the corners where it still bites.
The JSON trick is not a copy operation
It looks like a copy. It is a serialization round trip, and JSON can only represent strings, numbers, booleans, null, arrays, and plain objects. Anything else gets mangled or dropped, and none of it throws:
const state = {
lastSavedAt: new Date('2026-10-30T09:00:00'),
flags: new Map([['email', true]]),
backupDir: undefined,
}
const copy = JSON.parse(JSON.stringify(state))
// copy.lastSavedAt is "2026-10-30T09:00:00.000Z", a string
// copy.flags is {}
// copy.backupDir is gone, the key just disappeared
The Date case usually announces itself later, when some other component calls .toLocaleDateString() on your string and crashes three files away from the actual bug. The Map case is nastier because nothing ever crashes. It returns undefined forever, exactly like my settings form did. And if your object happens to contain a circular reference, JSON.stringify throws a TypeError, which at least is loud.
structuredClone does the boring thing you wanted
structuredClone() is the same algorithm the browser already used for postMessage between windows and workers. It has been available in Chrome 98, Firefox 94, and Safari 15.4 since early 2022, and in Node since 17, so unless you support genuinely ancient runtimes you already have it (MDN has the full compatibility table). The call is one line and the results are what you would guess:
const copy = structuredClone(state)
copy.lastSavedAt instanceof Date // true
copy.flags instanceof Map // true
'backupDir' in copy // true, undefined is preserved
It handles everything JSON cannot: Date, Map, Set, RegExp, typed arrays, ArrayBuffer, Blob. It also survives circular references, because the algorithm tracks the objects it has already seen:
const node = { name: 'root' }
node.self = node
JSON.parse(JSON.stringify(node))
// TypeError: Converting circular structure to JSON
const clone = structuredClone(node)
clone.self === clone // true, the cycle survives correctly
Speed is a side benefit, not the reason to switch. On large plain objects the two approaches are closer than the benchmarks floating around suggest, and the numbers move with the engine. The real argument is correctness. The clone is a faithful copy instead of a lossy one.
Where it still fails, on purpose
Functions and DOM nodes throw a DataCloneError. The algorithm copies data, not behavior, and honestly that is the right call. Cloning a closure would open a philosophical debate, not a feature request.
Class instances are the subtle one. The data comes through, the class does not:
class Cart {
constructor(total) { this.total = total }
tax(rate) { return this.total * rate }
}
const cart = new Cart(100)
const copy = structuredClone(cart)
copy.total // 100
copy.tax // undefined, methods are not cloned
copy instanceof Cart // false
Property descriptors get flattened too. Getters become plain values, non-enumerable flags reset to the defaults. If you need a real class instance back, you still write a small mapper, something like Object.assign(new Cart(), structuredClone(cart)), or a proper reviver for bigger object graphs. That is fine. It forces you to be explicit about the boundary between data and behavior instead of pretending one clone call can carry both.
When the JSON round trip is still the right tool
I have stopped using it for cloning, but I have not stopped using it. There are moments when JSON's lossiness is exactly what you want: stripping functions out of an object before logging it, or making sure a payload is genuinely JSON-safe before sending it somewhere. That is serialization, not copying, and once you read the pattern that way, the data loss becomes a feature instead of a bug. For shallow copies, spread syntax is still the right answer. Worth saying too: half the deep clones I used to write were covering for not knowing who owned an object. When two parts of an app mutate the same nested data and nobody can say who is allowed to, the fix is clearer ownership, not another clone.
Swapping it in without breaking things
The migration itself is a one-line search and replace. What bites people is not the API, it is the type changes underneath: code that quietly depended on a Date coming back as a string now behaves differently, and vice versa. So before you swap a wide-reaching call site, write one test that deep-clones a realistic payload, mutates the copy, and asserts the original did not move. That single test catches the whole Date-as-string category before it ships. I keep a version of it in my snippet manager so I can paste it into whichever project is next.
It will not fix every copy in your codebase, and it should not try. But it removes the worst failure mode a copy can have: looking correct while quietly lying to you. If you are in a cleaning mood, this pairs well with the other silent failure I wrote up, promise error handlers that never fire.