[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-deep-copy-javascript-objects-structuredclone-vs-json":3},{"content":4,"lastModified":5},"\u003Cp>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: \u003Ccode>JSON.parse(JSON.stringify(state))\u003C\u002Fcode>. One of those settings was a \u003Ccode>Map\u003C\u002Fcode>, and the JSON round trip turned it into an empty object. Every lookup after that returned \u003Ccode>undefined\u003C\u002Fcode>, and the calling code politely substituted a default. Silent, plausible, wrong.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ccode>structuredClone()\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Ch2>The JSON trick is not a copy operation\u003C\u002Fh2>\n\n\u003Cp>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:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">const state = {\n  lastSavedAt: new Date('2026-10-30T09:00:00'),\n  flags: new Map([['email', true]]),\n  backupDir: undefined,\n}\n\nconst copy = JSON.parse(JSON.stringify(state))\n\u002F\u002F copy.lastSavedAt is \"2026-10-30T09:00:00.000Z\", a string\n\u002F\u002F copy.flags is {}\n\u002F\u002F copy.backupDir is gone, the key just disappeared\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>The Date case usually announces itself later, when some other component calls \u003Ccode>.toLocaleDateString()\u003C\u002Fcode> on your string and crashes three files away from the actual bug. The Map case is nastier because nothing ever crashes. It returns \u003Ccode>undefined\u003C\u002Fcode> forever, exactly like my settings form did. And if your object happens to contain a circular reference, \u003Ccode>JSON.stringify\u003C\u002Fcode> throws a \u003Ccode>TypeError\u003C\u002Fcode>, which at least is loud.\u003C\u002Fp>\n\n\u003Ch2>structuredClone does the boring thing you wanted\u003C\u002Fh2>\n\n\u003Cp>\u003Ccode>structuredClone()\u003C\u002Fcode> is the same algorithm the browser already used for \u003Ccode>postMessage\u003C\u002Fcode> 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 (\u003Ca href=\"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FstructuredClone\" rel=\"noopener noreferrer\" target=\"_blank\">MDN has the full compatibility table\u003C\u002Fa>). The call is one line and the results are what you would guess:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">const copy = structuredClone(state)\ncopy.lastSavedAt instanceof Date \u002F\u002F true\ncopy.flags instanceof Map        \u002F\u002F true\n'backupDir' in copy              \u002F\u002F true, undefined is preserved\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>It handles everything JSON cannot: \u003Ccode>Date\u003C\u002Fcode>, \u003Ccode>Map\u003C\u002Fcode>, \u003Ccode>Set\u003C\u002Fcode>, \u003Ccode>RegExp\u003C\u002Fcode>, typed arrays, \u003Ccode>ArrayBuffer\u003C\u002Fcode>, \u003Ccode>Blob\u003C\u002Fcode>. It also survives circular references, because the algorithm tracks the objects it has already seen:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">const node = { name: 'root' }\nnode.self = node\n\nJSON.parse(JSON.stringify(node))\n\u002F\u002F TypeError: Converting circular structure to JSON\n\nconst clone = structuredClone(node)\nclone.self === clone \u002F\u002F true, the cycle survives correctly\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>\u003Cimg src=\"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1555099962-4199c345e5dd?w=1200&q=80&auto=format&fit=crop\" alt=\"A laptop on a desk with JavaScript code open in a code editor\" loading=\"lazy\">\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>Where it still fails, on purpose\u003C\u002Fh2>\n\n\u003Cp>Functions and DOM nodes throw a \u003Ccode>DataCloneError\u003C\u002Fcode>. 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.\u003C\u002Fp>\n\n\u003Cp>Class instances are the subtle one. The data comes through, the class does not:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">class Cart {\n  constructor(total) { this.total = total }\n  tax(rate) { return this.total * rate }\n}\n\nconst cart = new Cart(100)\nconst copy = structuredClone(cart)\n\ncopy.total           \u002F\u002F 100\ncopy.tax             \u002F\u002F undefined, methods are not cloned\ncopy instanceof Cart \u002F\u002F false\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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 \u003Ccode>Object.assign(new Cart(), structuredClone(cart))\u003C\u002Fcode>, 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.\u003C\u002Fp>\n\n\u003Ch2>When the JSON round trip is still the right tool\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>Swapping it in without breaking things\u003C\u002Fh2>\n\n\u003Cp>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 \u003Ca href=\"\u002Fsnippetark\u002F\">my snippet manager\u003C\u002Fa> so I can paste it into whichever project is next.\u003C\u002Fp>\n\n\u003Cp>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, \u003Ca href=\"\u002Fposts\u002Fpromise-all-error-boundary-not-firing\u002F\">promise error handlers that never fire\u003C\u002Fa>.\u003C\u002Fp>\n","2026-10-30",1793589940177]