Skip to main content
TF
By Rohit V.8 min readArticle

structuredClone vs JSON Deep Copy in JavaScript

TF
ToolsFuel Team
Web development tools & tips
JavaScript code open in a dark code editor

Photo by Unsplash on Unsplash

Short Answer

> Quick answer: Reach for `structuredClone(value)`. It's built into every current browser, plus Node and Deno, and it copies `Date`, `Map`, `Set`, `RegExp`, typed arrays, `ArrayBuffer` and circular references correctly. The old `JSON.parse(JSON.stringify(value))` trick either mangles or throws on every one of those. The JSON round trip is only safe when the object is already plain JSON, meaning objects, arrays, strings, numbers, booleans and null, and nothing else.

The catch is that `structuredClone` refuses to copy functions, DOM nodes and class prototypes. Functions and DOM nodes throw a `DataCloneError`, and a class instance comes back as a plain object carrying the same fields but none of its methods.

What the JSON Round Trip Quietly Destroys

The reason `JSON.parse(JSON.stringify(obj))` survived so long is that it looks like it works. Log the result, see the same fields, ship it. The damage only shows up later, in the one code path that touched a `Date`.

A `Date` goes in and a string comes out. `JSON.stringify` calls `toJSON()` on it and produces an ISO string, and `JSON.parse` has no idea that string was ever a date, so it stays a string. Anything downstream calling `.getTime()` now throws a `TypeError`, usually three functions away from the clone that caused it.


Keys with a value of `undefined` disappear entirely. So do functions and `Symbol` values. That's not an error, it's a silent key deletion, which is the worst kind of bug because the object still looks structurally fine. `NaN` and `Infinity` come back as `null`, which is a different wrong answer in the same family.


A `Map` or a `Set` serializes to `{}`. Not an error, not a warning, just an empty object where your data used to be. `BigInt` is the one case that actually throws, because `JSON.stringify` refuses to serialize it at all. A circular reference throws too, with a `Converting circular structure to JSON` message that at least tells you what happened.


I've hit the `Map` case in real code, and it's a genuinely nasty half hour of debugging, because the clone succeeds, the shape of the object is unchanged and the only symptom is that a lookup returns `undefined`.

What structuredClone Handles Instead

`structuredClone` isn't a library function pretending to be a language feature. It exposes the structured clone algorithm the browser already used internally to pass data between a page and a worker, which is why it understands so many built in types.

`Date` stays a `Date`. `Map` and `Set` come back as real `Map` and `Set` instances with their entries intact. `RegExp` keeps its source and flags. `ArrayBuffer`, `TypedArray` and `DataView` all clone properly, which matters if you're moving binary data around.


Circular references work. The algorithm keeps a record of what it has already visited, so an object that points back at itself clones into a new object that points back at itself, rather than recursing until the stack dies. Two properties pointing at the same nested object stay pointing at the same nested object in the copy, which the JSON trip cannot preserve either.


`undefined` survives as `undefined`. `BigInt` clones fine. `Error` objects clone, keeping name and message. So the list of things it silently ruins is, in practice, empty. Either it copies your value correctly or it throws and tells you so.


That last property is the real argument for it. A failure you can see beats a corruption you can't.

The Three Things It Refuses to Clone

Functions throw a `DataCloneError`. There's no way around this, and it isn't a gap in the implementation. A function closes over variables in a scope that cannot be reproduced in another context, so copying it is not a well defined operation. If your object holds a callback, pull it out before cloning and put it back after.

DOM nodes throw for a related reason. A node belongs to a document, and a detached copy without that document isn't the same thing. Use `cloneNode()` when you actually want a copy of an element.


Class instances are the subtle one, because they don't throw. Clone a `class User` instance and you get an object with every own property copied and the prototype set to plain `Object`. `instanceof User` is now false and the methods are gone. This surprises people far more than the two cases that throw, since it fails quietly.


Property descriptors are also flattened. Getters and setters are evaluated once and stored as plain values, and anything non enumerable is dropped. If you're relying on `Object.defineProperty` behaviour, the clone won't carry it.


The honest summary is that `structuredClone` copies data, not behaviour. That's the right line to draw, and once you know where it sits, nothing about it is surprising.

Speed, and Why It Should Not Decide This

Benchmarks comparing the two get posted a lot, and the results flip depending on the shape of the object being copied. For small flat objects made of strings and numbers, the JSON round trip is often faster, because JSON serialization is a heavily tuned path in every engine. For deep or wide structures the gap narrows or reverses.

None of that should drive the decision. Both approaches are fast enough that you'd need to be cloning inside a hot loop for the difference to be measurable against everything else the page is doing, and if you are cloning in a hot loop, the fix is to stop cloning rather than to clone faster.


The real cost of the JSON trip is not CPU, it's the class of bug it introduces. A clone that runs 20 percent faster and turns a `Map` into `{}` is not a good trade at any speed.


There's one case where speed genuinely matters, and it's large binary data. `structuredClone` accepts a `transfer` list, so `structuredClone(buf, {transfer: [buf]})` hands ownership of an `ArrayBuffer` to the copy instead of duplicating the bytes. The original becomes unusable, which is the point. For megabytes of buffer that's the difference between instant and not.

Where It Runs, and What to Do About Old Targets

`structuredClone` has been available across Chrome, Edge, Firefox and Safari since March 2022, and it's in Node 17 and up and in Deno. Four years of support puts it comfortably past the line where a polyfill is worth carrying for most projects.

If you support something older, the fallback isn't the JSON trick, it's a real deep clone helper from a library you already have. Lodash `cloneDeep` handles the same built in types and doesn't have the silent data loss problem, which makes it a much better floor than JSON.


There's a related trap in transport. If the object is going over the wire as JSON anyway, then a `Date` is going to become a string regardless, and cloning isn't what's losing your type, the protocol is. That's a serialization problem to solve with an explicit reviver, not a cloning problem. Our guide on
reading JSON from an API covers where those conversions actually happen.

For inspecting an object mid debug, paste it into the
JSON formatter and the missing keys tend to jump out immediately once the structure is laid out. Read the MDN reference for the full list of clonable types.

Rules That Hold Up

Default to `structuredClone`. It's the only one of the two that either works or tells you it didn't, and that alone settles it for most code.

Use the JSON round trip when you specifically want JSON semantics, meaning you intend to drop functions and undefined keys because the result is about to be sent somewhere as JSON. Doing it on purpose is fine, doing it as a generic deep copy is where the trouble starts.


Strip functions before cloning rather than after hitting the error. If a config object carries a callback, keep the callback in a separate variable, clone the data, then attach it. It reads better than a try block around a clone.


Don't clone class instances at all. Give the class a `toJSON` or a static `from()` and be explicit, because a silently prototype stripped object is harder to trace than an obvious constructor call.


And check whether you need the copy. Half the clones I've removed from code existed to defend against a mutation that never happened. Freezing the object with `Object.freeze` or just not mutating it is cheaper than copying it on every render. If the copy exists because something async might change the value underneath you, the
event loop guide is the better thing to read first.

Frequently Asked Questions

Is structuredClone faster than JSON.parse(JSON.stringify())?

It depends on the object. Small flat objects often round trip through JSON faster, while deep structures narrow or reverse the gap. Neither is slow enough for the difference to matter outside a hot loop, so correctness should decide it.

Why does structuredClone throw a DataCloneError?

You passed something it cannot copy, almost always a function, a DOM node, or an object holding one of those. Remove the function before cloning and reattach it afterwards.

Does structuredClone copy class methods?

No. Own properties are copied but the prototype is not, so the result is a plain object and instanceof returns false. Give the class an explicit from() or toJSON() instead of cloning it.

What happens to a Date in JSON.parse(JSON.stringify(obj))?

It becomes an ISO string and stays a string, because JSON has no date type. Anything calling getTime() on it later throws. Pasting the result into the [JSON formatter](/tools/json-formatter) makes the change obvious.

Can I still use structuredClone in Node?

Yes, it has been a global in Node since version 17, and it is available in Deno too. No import is needed in either runtime.

What is the transfer option for?

It moves ownership of an ArrayBuffer to the clone instead of copying the bytes, which makes large binary clones effectively free. The original buffer becomes unusable afterwards, which is the intended trade.

Try ToolsFuel

23+ free online tools for developers, designers, and everyone. No signup required.

Browse All Tools