structuredClone() Explained: Deep Copies in JS
structuredClone() is a built-in JavaScript function for deep-copying values, including cycles and typed arrays, without the workarounds JSON tricks require.
structuredClone() is a global JavaScript function that creates a deep copy of a value — objects, arrays, Maps, Sets, dates, typed arrays, and more — without the caller needing a library or a serialization hack. It’s built into the runtime (browsers and Node.js alike), and it correctly handles cases that the old JSON.parse(JSON.stringify(x)) trick gets wrong.
Why deep copies are hard in JavaScript
Assigning an object to a new variable copies a reference, not the data:
const original = { user: { name: "Alice" } };
const shallow = original;
shallow.user.name = "Bob";
console.log(original.user.name); // "Bob" — same object underneath
A shallow copy via spread ({ ...original }) or Object.assign only copies the top level — nested objects are still shared references. To fully decouple a copy from its source, every nested object needs its own copy too. That’s a deep clone, and for years JavaScript had no built-in way to do it correctly.
The JSON round-trip problem
The common workaround was JSON.parse(JSON.stringify(value)). It’s concise, but it silently breaks on anything JSON can’t represent:
undefinedvalues, functions, and symbols are dropped entirely.Dateobjects become strings, not dates.Map,Set,RegExp, and typed arrays serialize incorrectly or throw.- Circular references throw a
TypeErrorimmediately. NaNandInfinitybecomenull.
For simple plain-data objects this is fine. For anything richer, it’s a source of quiet bugs — a cloned Date that’s actually a string, a Map that’s now {}.
What structuredClone() handles correctly
const original = {
createdAt: new Date(),
tags: new Set(["a", "b"]),
scores: new Map([["alice", 10]]),
buffer: new Uint8Array([1, 2, 3]),
};
const copy = structuredClone(original);
copy.createdAt instanceof Date; // true
copy.tags instanceof Set; // true
copy.scores.get("alice"); // 10
It implements the structured clone algorithm — the same algorithm browsers already used internally to pass data to Web Workers via postMessage and to store values in IndexedDB. structuredClone() just exposes that algorithm directly as a callable function instead of requiring you to round-trip through a worker or a database.
It also handles circular references, since the algorithm tracks object identity during the walk rather than serializing to a flat string:
const node = { name: "root" };
node.self = node;
const cloned = structuredClone(node); // works fine
cloned.self === cloned; // true
What it can’t clone
Structured clone is not a universal copy mechanism. It throws a DataCloneError on:
- Functions and class methods (the algorithm has no way to serialize executable code).
- DOM nodes.
- Property accessors (getters/setters) — only the current value is copied, and the resulting property is a plain data property.
Errorobjects lose their prototype chain in most engines (this varies by implementation and is worth testing if you rely oninstanceofafter cloning).
If an object contains any of these, you’ll need a manual clone or a library that lets you customize per-type behavior.
structuredClone() vs the JSON trick vs manual cloning
| | structuredClone() | JSON.parse(JSON.stringify()) | Manual/recursive clone |
|---|---|---|
| Dates, Maps, Sets | Preserved correctly | Broken (dates → strings, Maps/Sets → {}) | Correct if you write the cases |
| Circular references | Supported | Throws | Supported if you write the cases |
| Functions | Throws DataCloneError | Silently dropped | Depends on implementation |
| undefined values | Preserved | Dropped | Depends on implementation |
| Performance | Native, fast | Fast for small plain objects | Slowest, but fully customizable |
For most application state — form data, API responses stored in memory, values passed between a main thread and a Web Worker — structuredClone() is the right default. Reach for a library only when you need to clone class instances with custom prototypes or skip specific properties during the copy.
A practical use: resetting state without a fetch
A common pattern is cloning a “known good” object to reset editable UI state without re-fetching from the server:
const serverState = await fetchConfig();
let draft = structuredClone(serverState);
function resetDraft() {
draft = structuredClone(serverState);
}
Because the clone is fully independent, mutating draft — even deeply, editing nested objects — never touches serverState. That’s the property a shallow copy or a naive reference assignment can’t give you, and it’s the same class of problem that comes up with immutable state updates; see optimistic UI updates for a related pattern that depends on cleanly separating “current” state from “pending” state.
Transferring instead of copying
For large binary data — an ArrayBuffer backing a big typed array, for instance — cloning still means copying every byte, which costs time and memory proportional to the size of the data. When the sending side no longer needs the original after handing it off, structuredClone() accepts a second argument that transfers ownership of specific objects instead of copying them:
const buffer = new ArrayBuffer(1024 * 1024 * 16); // 16 MB
const copy = structuredClone(buffer, { transfer: [buffer] });
buffer.byteLength; // 0 — ownership moved, not copied
This is the same transfer mechanism used when passing data to a Web Worker via postMessage, and it matters most when moving large buffers between a main thread and a worker, where an actual byte-for-byte copy would otherwise block the thread proportional to the buffer’s size. It’s a niche case compared to ordinary object cloning, but worth knowing about before reaching for a copy when a transfer would do.
The takeaway
structuredClone() gives JavaScript a native, correct deep-copy function that handles dates, Maps, Sets, typed arrays, and circular references — all cases the JSON.stringify round-trip gets wrong. It can’t clone functions, DOM nodes, or getters/setters, so reach for a manual or library-based clone when an object needs that. For everyday application state, it’s simpler and safer than the workarounds it replaces.
Tagged
Keep reading
Takina · · 5 min read Promise.all() vs allSettled() vs race() Compared
Promise.all() fails fast, allSettled() waits for every result, and race() returns whichever promise finishes first — how to choose correctly.
Takina · · 4 min read JavaScript Spread vs Rest Operators, Explained
The spread operator (...) expands an iterable into individual elements; the rest operator collects elements back into an array. Same syntax, opposite jobs.
Takina · · 4 min read ResizeObserver API Explained: Watching Element Size
The ResizeObserver API lets JavaScript watch an element's box size and react without polling or resize-event hacks. How it works and when to use it.