Programming

Deep Copy in JavaScript: structuredClone vs JSON and Spread

By Nawawi JamiliPublished 6 min read

At some point, every JavaScript developer changes a "copy" of an object and finds that the original changed too. The usual fixes are the spread operator, Object.assign() and the old JSON.parse(JSON.stringify(obj)) trick. The first two only copy one level deep, and the JSON trick silently breaks anything that is not plain JSON. structuredClone() is the built-in deep copy that browsers and Node now provide. It handles most of what the others get wrong.

Everything below was run in Node.js 18.20.4, and the output is pasted exactly as Node printed it.

Spread and Object.assign are shallow

Spreading an object creates a new outer object, but nested objects and arrays are not copied. Both objects point at the same inner values:

javascript
const original = {
  name: 'server-01',
  tags: ['web', 'prod'],
  limits: { cpu: 2, memory: 4096 },
};
const copy = { ...original };
copy.name = 'server-02';
copy.tags.push('eu');
copy.limits.cpu = 8;
console.log(original);
console.log(copy.tags === original.tags);
const copy2 = Object.assign({}, original);
console.log(copy2.limits === original.limits);
output
{
  name: 'server-01',
  tags: [ 'web', 'prod', 'eu' ],
  limits: { cpu: 8, memory: 4096 }
}
true
true

Changing name on the copy did not change the original, because strings are copied by value. Pushing to tags and changing limits.cpu also changed the original. For flat objects of strings and numbers, spread is fine and fast. As soon as there is nesting, you need a deep copy.

The JSON round trip and what it breaks

JSON.parse(JSON.stringify(obj)) does produce a deep copy, and for years it was the standard answer. It works by turning the object into a JSON string and parsing it back. So anything that JSON cannot represent is changed or lost on the way:

javascript
const original = {
  created: new Date('2026-10-13T10:30:00Z'),
  retries: undefined,
  onError() {},
  seen: new Set(['a', 'b']),
  hosts: new Map([['web', '10.0.0.5']]),
  ratio: NaN,
  big: Infinity,
  pattern: /^srv-\d+$/i,
};
const copy = JSON.parse(JSON.stringify(original));
console.log(copy);
console.log(typeof copy.created);
output
{
  created: '2026-10-13T10:30:00.000Z',
  seen: {},
  hosts: {},
  ratio: null,
  big: null,
  pattern: {}
}
string

Here is what went wrong:

  • The Date became a string. Any later copy.created.getTime() throws.
  • retries: undefined and the onError method vanished completely.
  • The Set, Map and RegExp became empty objects, with their contents gone.
  • NaN and Infinity became null.

None of that raises an error, which is the real problem. The copy looks correct until some code in another part of the program gets a string where it expected a date. And there is one case where it does fail with an error: an object that refers to itself.

javascript
const node = { name: 'parent' };
node.self = node;
try {
  JSON.parse(JSON.stringify(node));
} catch (e) {
  console.log(e.name + ': ' + e.message.split('\n')[0]);
}
output
TypeError: Converting circular structure to JSON

Tree structures with parent links, DOM-like graphs and many ORM objects have exactly this shape.

structuredClone()

structuredClone() uses the structured clone algorithm, the same one browsers use to pass data to Web Workers or store it in IndexedDB. It is a global function in all current browsers and in Node.js from version 17:

terminal
$ node -e 'console.log(process.version, typeof structuredClone)'
v18.20.4 function

Here is the same difficult object, without the method (more on that below):

javascript
const original = {
  created: new Date('2026-10-13T10:30:00Z'),
  retries: undefined,
  seen: new Set(['a', 'b']),
  hosts: new Map([['web', '10.0.0.5']]),
  ratio: NaN,
  big: Infinity,
  pattern: /^srv-\d+$/i,
  bytes: new Uint8Array([1, 2, 3]),
  limits: { cpu: 2, memory: 4096 },
};
const copy = structuredClone(original);
console.log(copy);
console.log(copy.created instanceof Date, copy.created === original.created);
console.log(copy.limits === original.limits);
console.log('retries' in copy);
output
{
  created: 2026-10-13T10:30:00.000Z,
  retries: undefined,
  seen: Set(2) { 'a', 'b' },
  hosts: Map(1) { 'web' => '10.0.0.5' },
  ratio: NaN,
  big: Infinity,
  pattern: /^srv-\d+$/i,
  bytes: Uint8Array(3) [ 1, 2, 3 ],
  limits: { cpu: 2, memory: 4096 }
}
true false
false
true

Everything kept its type. The date is a real Date but a different object, the nested limits is a new copy, and the undefined property is still there. Typed arrays, ArrayBuffer, Blob, Error objects and BigInt are supported too.

Circular references and shared references are preserved rather than breaking the copy:

javascript
const node = { name: 'parent', children: [] };
const child = { name: 'child', parent: node };
node.children.push(child);
node.self = node;
const copy = structuredClone(node);
console.log(copy.self === copy);
console.log(copy.children[0].parent === copy);
console.log(copy.self === node);
// shared references stay shared
const shared = { v: 1 };
const pair = structuredClone({ a: shared, b: shared });
console.log(pair.a === pair.b);
output
true
true
false
true

The copied child points at the copied parent, not the original. This is exactly what you want from a deep copy of a graph. The JSON trick would have produced two separate objects for a and b even if there had been no cycle.

What structuredClone cannot clone

The algorithm copies data, not behaviour. Functions cannot be cloned. JSON drops them silently, but structuredClone() throws a DataCloneError instead. The same goes for symbols:

javascript
try {
  structuredClone({ name: 'job', run() {} });
} catch (e) {
  console.log(e.name + ': ' + e.message);
}
try {
  structuredClone({ cb: () => 1 });
} catch (e) {
  console.log(e.name + ': ' + e.message);
}
try {
  structuredClone(Symbol('id'));
} catch (e) {
  console.log(e.name + ': ' + e.message);
}
output
DataCloneError: run() {} could not be cloned.
DataCloneError: () => 1 could not be cloned.
DataCloneError: Symbol(id) could not be cloned.

DOM nodes are also refused with a DataCloneError in browsers. Use element.cloneNode(true) for those. Node.js has no DOM, so I could not show that here.

The surprise for most people is classes. An instance of your own class clones without error, but the copy is a plain object. The prototype, and with it every method, is gone:

javascript
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  length() {
    return Math.hypot(this.x, this.y);
  }
}
const p = new Point(3, 4);
const copy = structuredClone(p);
console.log(copy);
console.log(copy instanceof Point);
console.log(typeof copy.length);
// rebuild the instance yourself if you need the methods
const revived = Object.assign(new Point(), structuredClone(p));
console.log(revived instanceof Point, revived.length());
output
{ x: 3, y: 4 }
false
undefined
true 5

The same applies to any object with a custom prototype: only its own properties are copied, and inherited ones are not looked up. Getters are called once and stored as plain values, and non-enumerable properties are skipped:

javascript
const obj = {
  first: 'Ada',
  get greeting() { return 'Hello ' + this.first; },
};
Object.defineProperty(obj, 'secret', { value: 42, enumerable: false });
const copy = structuredClone(obj);
console.log(copy);
console.log(Object.getOwnPropertyDescriptor(copy, 'greeting'));
console.log('secret' in copy);
output
{ first: 'Ada', greeting: 'Hello Ada' }
{
  value: 'Hello Ada',
  writable: true,
  enumerable: true,
  configurable: true
}
false

The copy's greeting stays fixed at "Hello Ada" and will not change if you later set copy.first. If an object relies on getters, setters or methods, clone its data. Then rebuild it through its constructor or a factory function, as with Point above.

Is it slower than JSON?

I timed both on an array of 10,000 small objects, 50 copies each, on one machine (an Intel Core i7-8565U laptop, Node 18.20.4). Across three runs, JSON.parse(JSON.stringify()) took between 9.9 and 10.1 ms per copy. structuredClone() took between 11.4 and 11.6 ms. So for plain JSON-shaped data on this setup the JSON trick was slightly faster, by about 15 percent. That difference is too small to matter in most code, and in return you get correct dates, maps, sets and cycles. Measure your own data before letting speed decide.

Which to use

  • Spread / Object.assign(): flat objects, or when you deliberately want to share nested values, for example when updating one key of a state object.
  • structuredClone(): the default deep copy for data: configuration, API responses, state snapshots, anything with dates, maps, sets or cycles.
  • JSON.parse(JSON.stringify()): only when you are going through JSON anyway, such as before sending data over the network, and you know the data is plain JSON.
  • Your own copy method: class instances whose methods or invariants must survive.

One extra: structuredClone() takes an option to transfer an ArrayBuffer instead of copying it. This moves large binary data without duplicating memory, and leaves the original empty. The MDN page for structuredClone() lists every supported type.