Blog / Coding tips

JavaScript Deep Copy Object: structuredClone vs JSON vs Spread

Cover image for JavaScript Deep Copy Object: structuredClone vs JSON vs Spread

To deep copy an object in JavaScript, use const copy = structuredClone(original). It's built into every modern browser and into Node.js 17 and later, so you don't need a library. It copies nested objects and arrays all the way down, keeps Date, Map, Set and RegExp values as the right types, and even copies circular references. The spread operator ({ ...obj }) and Object.assign() only make a shallow copy, so nested objects are still shared with the original. The old JSON.parse(JSON.stringify(obj)) trick does copy deeply, but it quietly turns dates into strings, drops undefined, empties Maps and Sets, and crashes on circular data. The one thing structuredClone() can't copy is functions, and it doesn't keep class instances as their class.

In this guide I show what "copy" really means for objects, test every common method side by side on the same data, and cover the edge cases people hit in real projects: arrays of objects, classes, functions, frozen objects and speed. Every example ran on Node.js 20.19.2 on 7 October 2026, and the output is pasted from the terminal. structuredClone() behaves the same way in Chrome, Firefox, Safari and Edge.

Shallow copy vs deep copy: why your "copy" changes the original

Objects in JavaScript are stored by reference. When you write const b = a, you don't get a second object; you get a second name for the same one. A shallow copy makes a new outer object, but its properties still point at the same nested objects. A deep copy makes new objects at every level.

const original = { name: "Amira", address: { city: "Leeds" }, tags: ["admin"] };

const sameRef = original;
const shallow = { ...original };
const deep = structuredClone(original);

shallow.address.city = "York";   // nested object is shared
deep.tags.push("editor");        // deep copy is independent

console.log(sameRef === original);              // true
console.log(shallow === original);              // false
console.log(shallow.address === original.address); // true
console.log(original.address.city);             // York
console.log(original.tags, deep.tags);
true
false
true
York
[ 'admin' ] [ 'admin', 'editor' ]

Line by line:

  • sameRef === original is true. Plain assignment never copies anything.
  • shallow === original is false, so the spread operator did make a new outer object.
  • But shallow.address === original.address is true. The nested address object is shared, so changing shallow.address.city to "York" also changed the original. This is the bug that sends most people searching for a deep copy.
  • deep.tags.push("editor") didn't touch original.tags, because structuredClone() created a new array.

Whether you need a deep copy depends on your data. If every property is a primitive (strings, numbers, booleans), a shallow copy is a full copy and spread is perfect. As soon as there's an object or array inside, you need to think about it. The same rule applies to the arrays you build with map, filter and reduce, which I cover in my guide to JavaScript map, filter and reduce: they create new arrays, but the objects inside are the same ones.

Some array methods don't even create a new array. sort(), reverse() and splice() change the original in place, so a list you thought was safe can be reordered under your feet. Newer methods such as toSorted() and toReversed() return a new array instead, but it's still a shallow copy: the objects inside are shared. My guide on how to sort an array of objects by property shows toSorted() in action and explains when the original order matters.

structuredClone vs JSON.stringify: what each one keeps

JSON.parse(JSON.stringify(obj)) was the standard deep-copy trick for years, and plenty of tutorials still recommend it. It works for plain JSON-shaped data, but here's what happens to a realistic object with a date, a Map, a Set, an undefined value, NaN and a regular expression.

const order = {
  id: 42,
  placed: new Date("2026-10-07T09:30:00Z"),
  items: new Map([["sku-1", 2]]),
  coupons: new Set(["AUTUMN10"]),
  note: undefined,
  total: NaN,
  pattern: /^GB\d+$/i,
};

const viaJson = JSON.parse(JSON.stringify(order));
const viaClone = structuredClone(order);

console.log("JSON  :", viaJson);
console.log("clone :", viaClone);
console.log("placed is Date?", viaJson.placed instanceof Date, viaClone.placed instanceof Date);
console.log("has note key?", "note" in viaJson, "note" in viaClone);
JSON  : {
  id: 42,
  placed: '2026-10-07T09:30:00.000Z',
  items: {},
  coupons: {},
  total: null,
  pattern: {}
}
clone : {
  id: 42,
  placed: 2026-10-07T09:30:00.000Z,
  items: Map(1) { 'sku-1' => 2 },
  coupons: Set(1) { 'AUTUMN10' },
  note: undefined,
  total: NaN,
  pattern: /^GB\d+$/i
}
placed is Date? false true
has note key? false true

The JSON version damaged five of the seven properties without a single error:

  1. placed became the string '2026-10-07T09:30:00.000Z'. Calling copy.placed.getFullYear() would now throw, and comparing dates would compare strings instead.
  2. items (a Map) and coupons (a Set) became empty objects {}. The data is simply gone.
  3. note: undefined disappeared completely, so "note" in copy is false. Code that checks for the key will behave differently.
  4. total: NaN became null.
  5. pattern became {}.

structuredClone() kept every one of them, with the right types: instanceof Date is true, the Map and Set still hold their entries, and undefined and NaN survive. If you're sending data to a server, JSON is still the right format, and my guide to sending JSON with fetch covers that. Just don't use a JSON round trip as a copying tool for data that isn't plain JSON.

Circular references

A circular reference is an object that points back to itself, directly or through a chain. Trees with parent links, graph data and some framework objects have them.

const team = { name: "Platform" };
team.self = team;

try {
  JSON.parse(JSON.stringify(team));
} catch (e) {
  console.log("JSON:", e.name, "-", e.message.split("\n")[0]);
}
const copy = structuredClone(team);
console.log("clone keeps the loop:", copy.self === copy, "and is new:", copy !== team);
JSON: TypeError - Converting circular structure to JSON
clone keeps the loop: true and is new: true

JSON.stringify() throws a TypeError. structuredClone() copies the loop correctly: the copy's self property points at the copy, not the original, which is exactly what you want.

What structuredClone can't copy: functions and classes

structuredClone() uses the structured clone algorithm, the same one browsers use to pass data to Web Workers and store it in IndexedDB. It's designed for data, not behaviour, and that shows in two places.

class Basket {
  constructor() { this.items = []; }
  add(x) { this.items.push(x); return this; }
  get count() { return this.items.length; }
}

try {
  structuredClone({ greet() { return "hi"; } });
} catch (e) {
  console.log(e.name + ":", e.message);
}

const b = new Basket().add("tea");
const c = structuredClone(b);
console.log("instanceof Basket:", c instanceof Basket);
console.log("count getter:", c.count, "| own keys:", Object.keys(c));
console.log("add is", typeof c.add);
DataCloneError: greet() { return "hi"; } could not be cloned.
instanceof Basket: false
count getter: undefined | own keys: [ 'items' ]
add is undefined
  • Functions throw a DataCloneError. An object with a method, such as { greet() {} }, can't be cloned at all. The error message names the function, which helps you find it. DOM elements and some host objects throw the same error.
  • Class instances become plain objects. The copy of a Basket has its own data (items), but instanceof Basket is false, the count getter is gone, and the add method is undefined. The prototype chain isn't copied, and neither are getters, setters or property descriptors.

Copying class instances properly

The cleanest fix is to give the class its own clone() method that builds a new instance and deep-copies only the data.

class Basket {
  constructor(items = []) { this.items = items; }
  add(x) { this.items.push(x); return this; }
  clone() { return new Basket(structuredClone(this.items)); }
}
const a = new Basket(["tea"]);
const b = a.clone().add("scones");
console.log(a.items, b.items, b instanceof Basket);
[ 'tea' ] [ 'tea', 'scones' ] true

The original basket still has only tea, the copy has tea and scones, and the copy is a real Basket with its methods. This is more honest than a generic deep-copy function too, because the class decides which fields are data and which are things like database connections that should never be copied.

A small deep copy function that keeps methods

If you really need a generic copy that keeps prototypes and functions, you can write one. This version handles circular references with a WeakMap, copies arrays and dates, and keeps the prototype of every object.

function deepCopy(value, seen = new WeakMap()) {
  if (value === null || typeof value !== "object") return value;
  if (seen.has(value)) return seen.get(value);
  if (value instanceof Date) return new Date(value);
  if (Array.isArray(value)) {
    const out = [];
    seen.set(value, out);
    for (const item of value) out.push(deepCopy(item, seen));
    return out;
  }
  const out = Object.create(Object.getPrototypeOf(value));
  seen.set(value, out);
  for (const key of Reflect.ownKeys(value)) out[key] = deepCopy(value[key], seen);
  return out;
}

const user = { name: "Tom", born: new Date("1999-02-01"), roles: ["a"], hello() { return "hi " + this.name; } };
user.me = user;
const copy = deepCopy(user);
copy.roles.push("b");
console.log(user.roles, copy.roles);
console.log(copy.hello(), copy.me === copy, copy.born instanceof Date);
[ 'a' ] [ 'a', 'b' ]
hi Tom true true

It's about 20 lines and it works for everyday objects, but notice what it doesn't do: Maps, Sets, typed arrays and regular expressions would need their own branches. That's why I treat this as a last resort. In order of preference, I use structuredClone(), then a clone() method on the class, then Lodash's cloneDeep() if the project already includes Lodash, and only then a hand-written function.

Deep copy an array of objects

Arrays behave like objects, so spreading an array copies only the outer array. This is the version of the bug I see most in React and Vue state updates.

const rows = [{ id: 1, qty: 2 }, { id: 2, qty: 5 }];

const shallow = [...rows];
shallow[0].qty = 99;
console.log("after spread edit, original qty:", rows[0].qty);

rows[0].qty = 2;
const deep = structuredClone(rows);
deep[0].qty = 99;
console.log("after structuredClone edit, original qty:", rows[0].qty);

const mapped = rows.map(r => ({ ...r }));   // one level deep is enough here
mapped[1].qty = 0;
console.log("after map+spread edit, original qty:", rows[1].qty);
after spread edit, original qty: 99
after structuredClone edit, original qty: 2
after map+spread edit, original qty: 5
  • [...rows] made a new array, but shallow[0] is the same object as rows[0], so setting qty to 99 changed the original.
  • structuredClone(rows) copied every object, so the original stayed at 2.
  • rows.map(r => ({ ...r })) copies one level inside the array. It's enough here because each row only holds numbers, and it's often what you want when you're updating one item in a list. If the rows had nested objects of their own, you'd be back to sharing them.

The rule of thumb: copy as deep as your data goes, and no deeper. For a list of flat records, map plus spread is clear and fast. For anything nested or unknown, use structuredClone(). If the copy is going to be saved, for example to localStorage in a small app like my habit tracker built with JavaScript and localStorage, remember that storage only holds strings, so you'll serialise to JSON there anyway, and dates will need converting back when you load them.

Object.assign, frozen objects and speed

Object.assign is shallow too

const defaults = { theme: "dark", layout: { sidebar: true } };
const a = Object.assign({}, defaults);
a.layout.sidebar = false;
console.log("Object.assign nested change leaks:", defaults.layout.sidebar);
const frozen = Object.freeze({ inner: { n: 1 } });
const copy = structuredClone(frozen);
copy.inner.n = 2;
console.log("clone of frozen is frozen?", Object.isFrozen(copy), copy.inner.n, frozen.inner.n);
Object.assign nested change leaks: false
clone of frozen is frozen? false 2 1

Object.assign({}, defaults) behaves exactly like spread: the nested layout object was shared, so setting sidebar to false on the copy changed the defaults too. This is a nasty bug in settings code, because the next user to load the defaults gets the previous user's choices. The second test shows another detail: cloning a frozen object gives you an unfrozen copy. Object.isFrozen(copy) is false, and changing copy.inner.n didn't affect the original. That's usually what you want from a copy, but if you rely on freezing to protect data, freeze the copy again.

Is structuredClone slower than JSON?

I timed both on an object holding 20,000 rows, averaging 20 runs after a warm-up.

const big = { rows: Array.from({ length: 20000 }, (_, i) => ({ id: i, name: "row " + i, tags: ["a", "b"], meta: { ok: true } })) };
function time(label, fn) {
  fn(); const t = performance.now();
  for (let i = 0; i < 20; i++) fn();
  return [label, (performance.now() - t) / 20];
}
const r = [time("JSON", () => JSON.parse(JSON.stringify(big))), time("structuredClone", () => structuredClone(big))];
const [a, b] = r;
console.log("both finished; faster on this run:", a[1] < b[1] ? a[0] : b[0]);
both finished; faster on this run: JSON

On this run and this machine, the JSON round trip was faster for this plain data, which isn't surprising: the web.dev article on structuredClone() notes that V8 heavily optimises JSON.parse() for exactly this pattern. The exact milliseconds change from run to run, so I'm not quoting them, but across three runs JSON was roughly one and a half to two times faster, and each copy of all 20,000 rows took tens of milliseconds either way. For the small objects in a typical form or settings panel, both are effectively instant. Pick the method that gives the correct result first. Only if profiling shows that copying is a real bottleneck should you reach for JSON, and only when you know the data is plain JSON. Often the better fix is to copy less: update the one nested object you're changing, rather than cloning the whole state on every keystroke. A debounce function also helps when copies happen on every input event.

Which deep copy method should you use?

After testing all of them on the same data, this is the order I'd pick them in for everyday work:

  1. Flat object or array of primitives: { ...obj } or [...arr]. A shallow copy is already a full copy here, and spread is the clearest syntax.
  2. List of flat records you're about to edit: rows.map(r => ({ ...r })). It copies exactly one level inside the array, which is all the data has.
  3. Anything nested, or data you didn't create yourself: structuredClone(obj). It's built in, handles dates, Maps, Sets and loops, and fails loudly instead of silently when it meets a function.
  4. Class instances: a clone() method on the class, so the copy keeps its prototype and the class decides what counts as data.
  5. Data that must leave the page (an API request, localStorage, a file): JSON.stringify(). That isn't copying, it's serialising, and you'll convert dates back when you read it.
  6. Lodash already in the project: cloneDeep() is fine and copies function references instead of throwing. I wouldn't add Lodash just for this any more, because structuredClone() now covers almost every case.

Deep copies and framework state

In React, Vue, Svelte and similar libraries, the reason you copy is usually so that the framework notices a change. Those libraries compare references, so a brand-new outer object is what triggers a re-render. That means you rarely need a full deep copy of the whole state. Instead, copy the path you're changing: setUser({ ...user, address: { ...user.address, city: "York" } }) creates a new user and a new address but reuses everything else. Cloning the whole tree with structuredClone() works too, but it gives every nested object a new identity, which can make memoised components re-render for no reason.

The transfer option

structuredClone() takes a second argument, { transfer: [buffer] }. Instead of copying a large ArrayBuffer, it moves it into the copy, and the original buffer becomes unusable. You'll only need this when you're handling big binary data, such as image pixels or audio, and want to avoid holding two copies in memory at once. Here's what that looks like with an 8 MB buffer:

const pixels = new ArrayBuffer(8 * 1024 * 1024);   // 8 MB
const job = { name: "thumbnail", pixels };
const moved = structuredClone(job, { transfer: [pixels] });
console.log("copy has", moved.pixels.byteLength, "bytes");
console.log("original now has", job.pixels.byteLength, "bytes");
copy has 8388608 bytes
original now has 0 bytes

The copy received all 8,388,608 bytes, and the original buffer was detached: its length dropped to zero, so any code still holding the original would now read nothing. That's the whole point of transferring, but it's also a trap if another part of your program still expects the original to work. For ordinary objects, leave the second argument out, and you'll never see this behaviour.

Common mistakes when copying objects in JavaScript

  1. Assuming spread is a deep copy. { ...obj } and [...arr] copy one level only. Anything nested is still shared.
  2. Using JSON.parse(JSON.stringify()) on real data. It silently converts dates to strings, drops undefined and functions, empties Maps and Sets, turns NaN into null and throws on circular data.
  3. Cloning objects that contain functions. structuredClone() throws DataCloneError. Remove the functions first, clone only the data, or give the class a clone() method.
  4. Expecting class instances to survive. A cloned class instance is a plain object without methods, getters or its prototype.
  5. Deep-copying when you don't need to. Large deep copies on every render waste time and memory. Copy only the branch you're changing.
  6. Forgetting older runtimes. structuredClone is not defined means you're on Node.js 16 or older, an old browser or a test runner with an outdated environment. Upgrade the runtime or the test environment rather than adding a polyfill if you can.
  7. Using Object.assign() to merge settings. Nested defaults get shared and mutated. Clone the defaults first, or merge level by level.

FAQ

How do I copy an object in JavaScript without reference?

Use structuredClone(obj). It returns a brand new object with new copies of every nested object and array, so changing the copy never affects the original. If the object only has primitive values (no nested objects or arrays), { ...obj } is enough. The full list of types it supports is on MDN's structuredClone() page.

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

For copying, yes. In my test, structuredClone() kept dates, Maps, Sets, undefined, NaN, regular expressions and circular references, while the JSON trick lost or changed five of seven properties without any warning. JSON was faster on plain data in my test (roughly one and a half to two times on 20,000 rows), but for everyday objects both are effectively instant. Use JSON for sending and saving data, and structuredClone() for copying it.

Does the spread operator make a deep copy?

No. The spread operator makes a shallow copy: the top-level object or array is new, but nested objects and arrays are shared with the original. In my example, changing shallow.address.city also changed original.address.city. Object.assign() behaves the same way.

How do I deep copy an object with functions in JavaScript?

structuredClone() throws a DataCloneError for functions. The best option is a clone() method on your class that creates a new instance and deep-copies the data, so the methods come from the class as normal. Alternatively, write a small recursive copy that keeps prototypes (shown above) or use Lodash's cloneDeep(), which copies function references rather than throwing.

Does structuredClone work in Node.js?

Yes, from Node.js 17 onwards it's a global function, so you call it without importing anything. I ran every example here on Node.js 20.19.2. If you see "structuredClone is not defined", check node -v, and check your test runner's environment too, because some older Jest set-ups didn't expose it.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related notes

PHP Sort Multidimensional Array by Value (usort, Tested)
Coding tips

PHP Sort Multidimensional Array by Value (usort, Tested)

usort($rows, fn($a, $b) => $a['grade'] <=> $b['grade']) sorts by one column. Descending, several columns, UK dates, case-insensitive names, keeping keys and array_multisort.

October 7, 2026 · 17 min read · 0 views