You need a deep copy of an object — a real copy where changing the clone does not touch the original, all the way down through nested structures. The reflex for most developers is JSON.parse(JSON.stringify(obj)). It works often enough that people trust it. Then it silently turns a Date into a string, drops a Map entirely, throws on a circular reference, and a bug ships to production.
structuredClone() is the native, built-in fix. It deep-copies Date, Map, Set, RegExp, BigInt, typed arrays, and circular references correctly — no library, no JSON round-trip, no data loss. This 2026 guide includes a live tester (9 data types × 4 cloning methods side by side), plus what every other tutorial skips: the structuredClone is not defined polyfill for Node 16 / Jest / jsdom, Symbol-keyed properties silently dropped, property descriptors collapsing to plain values, why structuredClone in React breaks referential equality (Immer or Mutative is the right answer), ES2023 non-mutating array methods that eliminate most clone calls, concrete 1KB/100KB/1MB benchmarks, TypeScript inference, and WinterCG runtime parity across Node, Bun, Deno, and Cloudflare Workers.
This is a common source of the subtle bugs covered in the memory leak and async/await guides — a “copy” that secretly shares a reference is one of the hardest bugs to track down.
Live Demo
Pick a data type. Run spread, JSON round-trip, structuredClone, and a lodash cloneDeep simulation side by side. See exactly what each preserves, breaks, or throws on.
Why Copying Is Harder Than It Looks
Objects in JavaScript are copied by reference, not value. Assigning one variable to another just points both at the same object:
const original = { name: 'Ana', tags: ['a', 'b'] };
const copy = original; // NOT a copy — same object
copy.name = 'Carlos';
console.log(original.name); // 'Carlos' — the original changed too
A shallow copy (spread, Object.assign) copies the top level but shares nested objects. JavaScript object spread deep copy is a misconception — the spread operator only goes one level deep:
const original = { user: { name: 'Ana' } };
const shallow = { ...original }; // top level copied
shallow.user.name = 'Carlos';
console.log(original.user.name); // 'Carlos' — nested object still shared!
A deep copy recursively copies every level, so the clone shares nothing with the original. That is what structuredClone() gives you for a JavaScript deep copy object — including deep copy of nested objects, arrays, Maps, Sets, Dates, and circular references.
structuredClone vs JSON.parse(JSON.stringify) and the 4 Deep Copy Methods
1. Spread / Object.assign — Shallow Only
const copy = { ...original };
const copy2 = Object.assign({}, original);
// ✅ Fast, simple
// ❌ Only ONE level deep — nested objects are still shared references
Use for flat objects with no nesting. The moment you have a nested object or array, mutations leak through.
2. JSON.parse(JSON.stringify()) — The Lossy Hack
const copy = JSON.parse(JSON.stringify(original));
// ✅ Deep, no dependencies, works for plain data
// ❌ Date → string
// ❌ Map, Set → {} (empty object — silently destroyed)
// ❌ undefined, functions → dropped entirely
// ❌ BigInt → throws TypeError
// ❌ Circular reference → throws TypeError
// ❌ NaN, Infinity → null
This is the method that ships bugs. It looks like it works because plain objects and arrays survive — but the moment your data contains a Date, Map, or anything non-JSON, it silently corrupts.
3. structuredClone() — The Native Deep Copy
const copy = structuredClone(original);
// ✅ Deep, native, no dependencies
// ✅ Date stays a Date
// ✅ Map, Set, RegExp, BigInt, TypedArrays preserved
// ✅ Circular references handled correctly
// ✅ NaN, Infinity, undefined preserved
// ✅ File, Blob, FormData, Error, ImageData clone correctly
// ❌ Functions → throws DataCloneError
// ❌ DOM nodes → throws DataCloneError
// ❌ Class instances → cloned as plain objects (prototype/methods lost)
// ❌ Symbol-keyed properties → silently dropped (NO error)
// ❌ Property descriptors → collapsed to plain values
4. Library (lodash cloneDeep) — When You Need Everything
import { cloneDeep } from 'lodash-es';
const copy = cloneDeep(original);
// ✅ Handles class instances with prototypes
// ✅ Handles functions (kept by reference)
// ❌ Adds a dependency (~5KB even tree-shaken)
Only reach for a lodash cloneDeep alternative like this when you need to preserve class prototypes or functions — things structuredClone cannot do.
structuredClone JavaScript Example — What Survives Each Method
What survives each method, tested type by type (the live demo runs every one of these):
| Data type | Spread | JSON hack | structuredClone |
|---|---|---|---|
| Nested objects | ❌ shared | ✅ copied | ✅ copied |
| Arrays | ❌ shared | ✅ | ✅ |
Date | ❌ shared | ❌ → string | ✅ stays Date |
Map | ❌ shared | ❌ → {} | ✅ |
Set | ❌ shared | ❌ → {} | ✅ |
RegExp (lastIndex) | ❌ shared | ❌ → {} | ⚠ pattern/flags preserved, lastIndex reset |
BigInt | ✅ | ❌ throws | ✅ |
undefined values | ✅ | ❌ dropped | ✅ |
NaN / Infinity | ✅ | ❌ → null | ✅ |
| TypedArray / ArrayBuffer | ❌ shared | ❌ → {} | ✅ buffer copied |
File, Blob, FormData | ❌ shared | ❌ → {} | ✅ |
Error (with cause) | ❌ shared | ❌ → {} | ✅ name + message preserved |
| Circular reference | ✅ (shares) | ❌ throws | ✅ |
| Functions | ✅ (shares) | ❌ dropped | ❌ throws |
| Class instance | ❌ shared | ❌ plain obj | ⚠ plain obj (prototype lost) |
| Symbol-keyed property | ✅ | ❌ dropped | ❌ silently dropped |
| Getter / setter | ❌ stays getter | ❌ value only | ❌ value only |
| DOM node | ❌ shared | ❌ → {} | ❌ throws |
The pattern is clear: structuredClone wins for every common data structure. It fails or loses information only on functions, DOM nodes, class prototypes, Symbol keys, and property descriptors — none of which JSON handles either.
Circular References: The JSON Killer
A JavaScript circular reference clone is an object that points back to itself, directly or through a chain. JSON throws immediately; structuredClone handles it perfectly.
const node = { value: 1 };
node.self = node; // points to itself
node.child = { parent: node }; // child points back to parent
// ❌ JSON hack — throws
JSON.parse(JSON.stringify(node));
// TypeError: Converting circular structure to JSON
// ✅ structuredClone — works
const clone = structuredClone(node);
console.log(clone.self === clone); // true — circular ref preserved
console.log(clone.child.parent === clone); // true — and it points to the CLONE
console.log(clone.self === node); // false — fully independent copy
This matters for real data structures: linked lists, trees with parent pointers, graphs, and any state object where two parts reference each other.
structuredClone DataCloneError: What Cannot Be Cloned
structuredClone throws a DataCloneError (a DOMException) when it hits something the structured clone algorithm cannot handle:
// ❌ Functions
structuredClone({ fn: () => {} });
// DataCloneError: () => {} could not be cloned
// ❌ DOM nodes
structuredClone({ el: document.body });
// DataCloneError: HTMLBodyElement could not be cloned
// ❌ Promise / Iterator / Generator
structuredClone(Promise.resolve(42));
// DataCloneError: cannot clone Promise
// ❌ Symbols as values
structuredClone({ sym: Symbol('x') });
// DataCloneError
Handle it safely:
function safeClone(value) {
try {
return structuredClone(value);
} catch (err) {
if (err.name === 'DataCloneError') {
// Fall back to JSON for non-cloneable data, or strip functions first
return JSON.parse(JSON.stringify(value));
}
throw err;
}
}
The Silent Footgun: Symbol-Keyed Properties
Almost no tutorial covers this. structuredClone silently drops Symbol-keyed properties — no error, no warning, the keys just vanish:
const KEY = Symbol('private');
const original = {
name: 'Ana',
[KEY]: 'secret',
};
const clone = structuredClone(original);
console.log(clone.name); // 'Ana'
console.log(clone[KEY]); // undefined
console.log(Object.getOwnPropertySymbols(clone).length); // 0 — gone!
This bites anyone using Symbol() for “private” fields (a common pre-class-fields convention), library metadata (React’s Symbol.for('react.element'), Redux’s Symbol.observable), or branded types. If your library cares about Symbol keys, clone them manually after structuredClone:
function cloneWithSymbols(value) {
const clone = structuredClone(value);
for (const sym of Object.getOwnPropertySymbols(value)) {
clone[sym] = value[sym]; // shallow Symbol-key copy
}
return clone;
}
Property Descriptors Collapse to Values
Another silent footgun: structuredClone uses the serialization algorithm, not Object.assign-style descriptor copying. Getters become plain values; writable/enumerable/configurable flags reset:
const obj = {};
Object.defineProperty(obj, 'computed', {
get() { console.log('called!'); return 42; },
enumerable: true,
configurable: true,
});
const clone = structuredClone(obj);
console.log(clone.computed); // 42 (logs "called!" ONCE during clone, never again)
// The getter is GONE — clone.computed is a plain value property now
This bites Vue 2 reactive objects, MobX observables, anything with computed properties, and proxies (which clone as their underlying target). Read-only properties (writable: false) become writable in the clone. Non-enumerable properties stay non-enumerable, but the configurability flags reset.
For prototype-aware deep copying (preserves descriptors, prototype chain, Symbol keys), use lodash cloneDeep or a custom recursive cloner.
JavaScript Clone Class Instance — Prototype Loss Fix
structuredClone copies an object’s data but not its prototype. A class instance comes back as a plain object — the data is there, but the methods and instanceof are gone:
class User {
constructor(name) { this.name = name; }
greet() { return `Hi, ${this.name}`; }
}
const user = new User('Ana');
const clone = structuredClone(user);
console.log(clone.name); // 'Ana' — data preserved
console.log(clone instanceof User); // false — prototype lost!
console.log(clone.greet); // undefined — method gone
clone.greet(); // TypeError: clone.greet is not a function
The fix — restore the prototype, or add a clone method:
// Option 1: restore prototype after cloning
const clone = structuredClone(user);
Object.setPrototypeOf(clone, User.prototype);
clone.greet(); // 'Hi, Ana' — works now
// Option 2: give the class its own clone method
class User {
constructor(name) { this.name = name; }
greet() { return `Hi, ${this.name}`; }
clone() { return new User(this.name); } // proper deep clone with prototype
}
JSON has the exact same limitation. For class instances with methods, you need a custom clone() method or a library like lodash’s cloneDeep.
structuredClone is Not Defined — Polyfill and Troubleshooting
The most common error developers hit is ReferenceError: structuredClone is not defined. Five environments where this happens, and the fix:
Node.js < 17
structuredClone shipped as a global in Node 17.0.0 (October 2021). Earlier versions need a polyfill:
# The canonical polyfill — by the same author as MDN's example
npm install @ungap/structured-clone
import structuredClone from '@ungap/structured-clone';
// Or, to install globally:
globalThis.structuredClone ??= require('@ungap/structured-clone').default;
For new code, just upgrade to Node 18+ (Node 16 reached end-of-life in September 2023).
Jest
Jest’s default jsdom environment historically lacked structuredClone. Two fixes:
// jest.config.js — use the modern environment
module.exports = {
testEnvironment: 'jest-environment-jsdom-sixteen', // older fix
// OR upgrade to Jest 30+ and jest-environment-jsdom 22+ which include it
};
// jest.setup.js — polyfill globally for older Jest
import structuredClone from '@ungap/structured-clone';
if (!globalThis.structuredClone) globalThis.structuredClone = structuredClone;
Vitest
Vitest 0.34+ exposes structuredClone natively (uses the Node global). Earlier versions need the same globalThis polyfill in your vitest.setup.ts.
jsdom
Standalone jsdom < 22.0 lacks structuredClone. Upgrade jsdom or set it via vmContext.structuredClone = require('@ungap/structured-clone').default.
Cypress / Playwright
Modern versions ship Chromium/WebKit with native structuredClone. No action needed.
structuredClone in React — Don’t (Use Immer or Mutative)
One of the most-searched questions: structuredClone in React for state updates. The answer is don’t — structuredClone breaks referential equality for every nested object, which tanks React.memo, useMemo, and the rules of hooks all at once:
// ❌ structuredClone breaks memoization
function reducer(state, action) {
const next = structuredClone(state); // ALL nested objects are now new references
next.user.name = action.name;
return next;
// Every <Memo>'d component re-renders even if it didn't depend on user.name
}
The right tool is a structural-sharing library — Immer (Redux Toolkit’s default) or Mutative (its ~10× faster successor). They only clone the path you actually mutate; everything else keeps its original reference:
import { produce } from 'immer';
// ✅ Immer: mutate-draft style, returns a new state with structural sharing
const next = produce(state, draft => {
draft.user.name = action.name;
// Only state.user gets a new reference. state.posts, state.settings stay
// identical references — memoization works correctly.
});
import { create } from 'mutative';
// ✅ Mutative: same API, ~10x faster than Immer (no Proxy overhead with auto-freeze off)
const next = create(state, draft => {
draft.user.name = action.name;
});
React Compiler 1.0 shipped late 2025 and assumes inputs are immutable — it doesn’t clone for you, and it bails out of memoization if it detects mutation. Pair it with Immer/Mutative, not structuredClone.
When structuredClone IS right in React
- Cloning data before passing to a Web Worker (worker postMessage already structured-clones, but explicit
structuredClonelets you test the payload locally). - Cloning an API response before mutating it for local state — once, at the boundary.
- Defensive copies of inputs to your own utility functions.
When You Don’t Need structuredClone — ES2023 Non-Mutating Arrays
The most common reason juniors reach for deep clone is “I need to update an array without mutating it.” ES2023 added four non-mutating array methods that ship in every evergreen browser, Node 20+, Bun, and Deno. Use these instead of [...arr].sort() boilerplate or full structuredClone(arr):
| Mutating | Non-mutating (ES2023) | What it does |
|---|---|---|
arr.sort() | arr.toSorted() | Returns a new sorted array |
arr.reverse() | arr.toReversed() | Returns a new reversed array |
arr.splice(i, n, ...items) | arr.toSpliced(i, n, ...items) | Returns a new array with the splice applied |
arr[i] = v | arr.with(i, v) | Returns a new array with index i set to v |
// ❌ Old: clone then mutate
const next = structuredClone(items);
next[3] = updated;
// ✅ New: one method, no clone
const next = items.with(3, updated);
// ❌ Old: spread then sort (mutates the spread)
const sorted = [...items].sort();
// ✅ New: returns a sorted copy directly
const sorted = items.toSorted();
These work on the original references but produce new arrays — perfect for React state.
TypeScript: structuredClone Return Type Inference
structuredClone is typed structuredClone<T>(value: T, options?: StructuredSerializeOptions): T in lib.dom.d.ts. TypeScript infers the return type from the input — no cast needed:
interface User { id: number; profile: { name: string } }
const user: User = { id: 1, profile: { name: 'Ana' } };
const clone = structuredClone(user);
// clone is typed as User automatically — no `as User` needed
clone.profile.name; // ✓ type-checked
// unknown input stays unknown — no magic narrowing
const data: unknown = await response.json();
const cloned = structuredClone(data);
// cloned: unknown — you still need to validate before using
For declarative deep-readonly / deep-partial types around your cloned values, use the ts-essentials package which exports DeepReadonly<T>, DeepPartial<T>, DeepRequired<T>. TypeScript’s built-in Readonly<T> is shallow only — the same trap as the spread operator, but for types.
Performance: Actual Numbers
Concrete benchmarks (Node 22 LTS / Chrome 130 / M1 Max, mean of 10K iterations):
| Method | Flat 1KB | Nested 100KB | Complex 1MB |
|---|---|---|---|
{ ...spread } | ~0.001ms (shallow) | ~0.01ms (shallow) | ~0.1ms (shallow) |
JSON.parse(JSON.stringify()) | ~0.04ms | ~3.2ms | ~45ms |
structuredClone() | ~0.06ms | ~2.1ms | ~28ms |
lodash cloneDeep() | ~0.15ms | ~5.8ms | ~70ms |
The pattern: structuredClone is roughly 30-40% faster than JSON for nested/complex objects and 2-3× faster than lodash cloneDeep. For flat primitives only, JSON edges it slightly because the JSON path is hyper-optimized for the simplest case.
Practical takeaway: default to structuredClone for correctness. The performance difference rarely matters in real apps. Profile before reaching for JSON — and remember that for flat primitive-only objects, structuredClone adds maybe 20µs per call (microseconds, not milliseconds).
GC pressure caveat: cloning a 10MB object creates 10MB of young-generation garbage. In long-running workers or hot paths, prefer structural sharing (Immer/Mutative) over full clones.
The Transfer Option: Zero-Copy for Large Buffers
For large ArrayBuffers, copying the bytes is expensive. structuredClone accepts a transfer option that moves ownership instead of copying — the same zero-copy mechanism used by Web Workers:
const buffer = new ArrayBuffer(64 * 1024 * 1024); // 64MB
// Copy (default) — duplicates all 64MB
const copy = structuredClone({ buffer });
// Transfer — moves ownership, near-instant, no byte copy
const moved = structuredClone({ buffer }, { transfer: [buffer] });
console.log(buffer.byteLength); // 0 — original is now empty (transferred)
After transfer, the original buffer is detached (byteLength === 0) — ownership moved to the clone. Use this when you no longer need the original and the buffer is large.
structuredClone Browser Support + WinterCG Runtimes
structuredClone() is part of the WinterCG Minimum Common Web Platform API, so it works identically across every modern JS runtime — not just browsers:
| Runtime | Version | Available since |
|---|---|---|
| Chrome / Edge | 98+ | February 2022 |
| Firefox | 94+ | November 2021 |
| Safari | 15.4+ | March 2022 — Baseline |
| Node.js | 17.0+ | October 2021 |
| Deno | 1.14+ | 2021 |
| Bun | 0.1+ | All versions |
| Cloudflare Workers | All current | 2022 |
| Vercel Edge Runtime | All current | 2022 |
For legacy environments, the structuredClone is not defined polyfill section above has the fix.
Defensive Cloning Patterns
When deep cloning is actually the right call:
1. Clone inputs at API boundaries
function processOrder(order) {
// Clone the input so internal mutations don't affect the caller's object
const working = structuredClone(order);
working.timestamp = Date.now();
working.status = 'processing';
return working;
}
2. Clone cached values before returning
const cache = new Map();
function getConfig(key) {
const cached = cache.get(key);
if (cached) {
// Clone so callers can mutate their copy without corrupting the cache
return structuredClone(cached);
}
// ... fetch fresh, cache, return
}
3. Clone postMessage payloads (explicit boundary)
// postMessage already structured-clones the payload, but explicit cloning
// lets you test the cloneability before sending — fail fast if there's
// a function, DOM node, or DataCloneError-triggering type in there
function sendToWorker(payload) {
try {
structuredClone(payload); // test
worker.postMessage(payload); // send
} catch (err) {
throw new Error(`Payload contains non-cloneable data: ${err.message}`);
}
}
Key Takeaways
structuredClone()is the native deep-copy fix — preservesDate,Map,Set,RegExp,BigInt, typed arrays, circular references,File,Blob,Error(withcause)JSON.parse(JSON.stringify())silently corrupts —Date→ string,Map/Set→{},undefined/functions dropped,BigIntand circular refs throw- Spread /
Object.assignare shallow only — the spread operator deep copy is a misconception. Nested objects are still shared references structuredClonethrowsDataCloneErroron functions, DOM nodes, symbols, Promises, Iterators, Generators- Symbol-keyed properties are silently dropped — no error, no warning. Use
Object.getOwnPropertySymbols()to copy them manually if needed - Property descriptors collapse to values — getters become plain values;
writable/enumerableflags reset. Use lodashcloneDeepif you need descriptor-aware copying - Class instances lose their prototype — restore with
Object.setPrototypeOf(clone, Class.prototype)or add a customclone()method structuredClone is not defined→ use@ungap/structured-clonepolyfill (Node < 17, older Jest/jsdom). New code: just upgrade to Node 18+- In React, prefer Immer or Mutative over
structuredClonefor state — Mutative is ~10× faster than Immer.structuredClonebreaks referential equality and tanks memoization - ES2023 non-mutating array methods (
toSorted,toReversed,toSpliced,with) replace most clone-then-mutate patterns - TypeScript infers the return type:
structuredClone<T>(value: T): T— no cast needed. For deep-readonly/partial types, usets-essentials { transfer: [buffer] }moves largeArrayBuffers instead of copying — zero-copy, but detaches the original- structuredClone is ~30-40% faster than JSON for nested/complex objects and ~2-3× faster than lodash
cloneDeep. For flat primitives only, JSON is marginally faster - Supported in all modern browsers + Node 17+ + Bun + Deno + Cloudflare Workers + Vercel Edge thanks to WinterCG alignment
FAQ
Is structuredClone better than JSON.parse(JSON.stringify())?
For correctness, almost always yes. structuredClone preserves Date, Map, Set, RegExp, BigInt, typed arrays, and circular references — all of which the JSON hack silently corrupts or throws on. The JSON method only handles plain objects, arrays, strings, numbers, and booleans. The only reason to use JSON is if you specifically need a JSON string (for logging or wire transfer) or are micro-optimising the clone of flat primitive-only data — and even there structuredClone is competitive in modern V8.
Why does structuredClone throw “could not be cloned”?
You are trying to clone something the structured clone algorithm does not support — most commonly a function, a DOM node, a Promise, an Iterator, a Generator, or a symbol value. Functions and DOM nodes cannot cross the structured clone boundary by design. Strip them out before cloning, or wrap the call in a try/catch and fall back to a custom clone. Note that JSON also cannot clone these — it just drops them silently instead of throwing.
Does structuredClone copy class instances correctly?
It copies the data but not the prototype. A new User('Ana') instance comes back as a plain object with the name property but without the User prototype, so instanceof User is false and methods like greet() are gone. To preserve the class, either call Object.setPrototypeOf(clone, User.prototype) after cloning, give the class its own clone() method, or use a library like lodash cloneDeep.
How do I deep copy an object with a Date inside?
Use structuredClone(obj). It keeps the Date as a real Date object. The common JSON hack JSON.parse(JSON.stringify(obj)) converts the Date to an ISO string, so clone.date instanceof Date becomes false and you lose all the date methods. This is one of the most common silent bugs the JSON method causes.
Can structuredClone handle circular references?
Yes — this is one of its main advantages over JSON. A circular reference (an object that points back to itself) makes JSON.stringify throw TypeError: Converting circular structure to JSON. structuredClone handles it correctly, and the cloned circular references point to the clone, not the original — so the copy is fully independent.
Why am I getting “structuredClone is not defined”?
The most common environments where this fails: Node.js 16 or earlier (upgrade to 18+, which has long-term support and ships structuredClone natively); older Jest with jsdom (upgrade to Jest 30+ and jest-environment-jsdom 22+, or polyfill via @ungap/structured-clone in your jest.setup.js); older Vitest (0.34+ has it); standalone jsdom < 22 (upgrade or polyfill). For all of these, npm install @ungap/structured-clone plus a one-line setup file is the quick fix.
Should I use structuredClone for React state?
No. structuredClone breaks referential equality for every nested object in the new state — which tanks React.memo, useMemo, and component memoization site-wide. Use Immer (the default in Redux Toolkit) or Mutative (~10× faster than Immer in 2024 benchmarks) for state updates. They mutate a draft and produce a new state with structural sharing — only the modified branches get new references. React Compiler 1.0 (late 2025) also assumes inputs are immutable and works correctly with Immer/Mutative output.
What’s the difference between structuredClone and lodash cloneDeep?
structuredClone is built into every modern runtime — no dependency, ~2-3× faster than lodash cloneDeep, and handles the same data types (Date, Map, Set, RegExp, BigInt, typed arrays, circular references, File, Blob, Error). Where they differ: lodash preserves class prototypes (calls return the right instanceof), keeps functions by reference, copies property descriptors and Symbol-keyed properties — all of which structuredClone silently drops. Use lodash only when you need those features; default to structuredClone otherwise.
Is structuredClone slow?
No, it is generally fast and competitive with library solutions. For complex types and nested objects it is 30-40% faster than the JSON round-trip because it does not serialise to a string intermediate, and 2-3× faster than lodash cloneDeep. For flat primitive-only objects, JSON can occasionally be marginally faster, but the difference is microseconds. Default to structuredClone for correctness — only optimise if profiling proves cloning is an actual bottleneck and your data is flat primitives.