EdgeCases Logo
Feb 2026
JavaScript
Deep
7 min read

structuredClone(): The Deep Copy That Isn't Always Deep

structuredClone() handles circular refs and built-in types, but silently drops prototypes, class instances, getters, and Symbol properties.

javascript
structuredclone
deep-copy
json
performance
edge-case

structuredClone() is the first native deep-copy primitive JavaScript has ever had. It handles circular references, Date, Map, Set, RegExp, ArrayBuffer, and typed arrays — things JSON.parse(JSON.stringify()) chokes on. But it's not the universal clone you think it is. Here's what it silently drops, what it throws on, and when you still need a custom solution.

What It Can't Clone (Throws)

structuredClone() uses the structured clone algorithm, originally designed for postMessage() between workers. Anything that can't cross a worker boundary can't be cloned:

// ❌ Functions — DataCloneError
structuredClone({ handler: () => {} });

// ❌ DOM nodes — DataCloneError
structuredClone({ el: document.body });

// ❌ Symbols as values — DataCloneError
structuredClone({ id: Symbol('unique') });

These aren't silent failures — they throw DOMException: DataCloneError. But the error only fires at runtime, and if the non-cloneable value is deeply nested, debugging which property caused it is painful. There's no path information in the error message.

What It Silently Drops

More dangerous than the throws are the silent losses:

class User {
  constructor(name) { this.name = name; }
  greet() { return `Hi, ${this.name}`; }
}

const original = new User('Alice');
const clone = structuredClone(original);

clone instanceof User;  // ❌ false
clone.greet();          // ❌ TypeError: clone.greet is not a function
Object.getPrototypeOf(clone) === Object.prototype; // ✅ true

The prototype chain is gone. The clone is a plain Object with the instance's own enumerable properties. Methods defined on the prototype, Symbol properties, getters/setters, and property descriptors (writable, enumerable, configurable) are all lost.

The Shared Reference Gotcha

structuredClone() preserves structural sharing within the object graph. This is actually correct behavior but surprises developers who expect fully independent nested copies:

const shared = ['a', 'b'];
const obj = { x: shared, y: shared };

const clone = structuredClone(obj);
clone.x === clone.y;        // ✅ true — same reference in clone
clone.x === obj.x;          // ❌ false — different from original

clone.x.push('c');
console.log(clone.y);       // ['a', 'b', 'c'] — y reflects x's change

The clone faithfully reproduces the original's reference topology. If two properties pointed to the same array in the original, they point to the same (cloned) array in the copy. This is by design — it's how circular references work — but it means mutations within the clone can cascade in unexpected ways.

Performance: It's Not Free

structuredClone() is significantly faster than JSON.parse(JSON.stringify()) for objects with Date, Map, or Set — because JSON has to serialize and reparse. But for plain objects and arrays (the most common case), JSON roundtrip is often faster in V8:

// Benchmark (plain objects, V8/Chrome 120+):
// JSON roundtrip:    ~0.8ms for 10K entries
// structuredClone:   ~1.2ms for 10K entries
// Spread (shallow):  ~0.1ms for 10K entries

// For objects with Maps/Sets/Dates:
// JSON roundtrip:    Incorrect output (loses types)
// structuredClone:   ~1.0ms and correct

Rule of thumb: if your data is JSON-safe (no Date, Map, Set, circular refs, or typed arrays) and performance matters, JSON roundtrip may still be faster. Profile before switching.

The Transfer Trick

structuredClone() accepts a transfer option that moves ArrayBuffers instead of copying them. The original becomes zero-length — detached. This is a zero-copy operation:

const buffer = new ArrayBuffer(1024 * 1024); // 1MB

const clone = structuredClone(
  { data: buffer },
  { transfer: [buffer] }
);

buffer.byteLength;       // 0 — original is detached
clone.data.byteLength;   // 1048576 — moved, not copied

This is identical to how postMessage() transfers work. Use it when passing large buffers between processing stages where you don't need the original. But be careful: accessing the detached buffer throws TypeError.

When to Use What

  • structuredClone() — Default choice. Use for anything with Date, Map, Set, circular refs, or typed arrays
  • JSON.parse(JSON.stringify()) — JSON-safe data only. Faster for large plain objects in V8. Drops undefined, Infinity, NaN, and functions silently
  • Spread / Object.assign — Shallow copy only. Fine for flat config objects
  • Custom deep clone — When you need prototype preservation, function cloning, or property descriptor retention

Error Handling Pattern

Since structuredClone() throws on non-cloneable values, wrap it when the input shape isn't guaranteed:

function safeClone(obj) {
  try {
    return structuredClone(obj);
  } catch (e) {
    if (e instanceof DOMException && e.name === 'DataCloneError') {
      // Fallback: strip non-cloneable values
      return JSON.parse(JSON.stringify(obj));
    }
    throw e;
  }
}

This pattern is especially useful in state management libraries where user-provided state might contain functions or DOM refs you can't predict.

Advertisement

Related Insights

Explore related edge cases and patterns

TypeScript
Expert
TypeScript Performance: Recursive Types & Build Times
6 min
Performance
Deep
The Web Animation Performance Tier List
6 min

Advertisement