Shallow Copy vs Deep Copy in JavaScript
A shallow copy duplicates an object's top-level properties but shares nested references; a deep copy duplicates everything recursively. How to do each.
A shallow copy duplicates an object or array’s top-level properties, but any property that’s itself an object or array is copied as a shared reference, not a new value. A deep copy duplicates every level recursively, so the copy shares nothing with the original — changing a nested value in one never affects the other. Most everyday copying in JavaScript is shallow by default, and the bugs that follow from that usually show up as “I copied it, but changing the copy somehow changed the original too.”
Why this distinction exists
JavaScript objects and arrays are reference types. A variable holding an object doesn’t hold the object’s data directly — it holds a reference to where that data lives in memory. When you copy an object, you have to decide, at every level of nesting, whether to copy the reference (fast, shares the underlying data) or copy the data itself (slower, produces an independent value). A shallow copy makes that decision once, at the top level, and stops there.
Shallow copy in practice
The common ways to shallow-copy in modern JavaScript:
const copy1 = { ...original }; // spread
const copy2 = Object.assign({}, original);
const arrCopy = [...originalArray]; // arrays work the same way
All three produce a new top-level object or array. If original is { name: "Ada", address: { city: "London" } }, copy1.name is an independent string — reassigning it doesn’t touch original.name. But copy1.address is the same object as original.address. Mutate copy1.address.city and original.address.city changes too, because both properties point at the same nested object in memory.
This is exactly why Object.freeze() only freezes the top level by default — freezing an object doesn’t recursively freeze its nested objects, for the same structural reason a shallow copy doesn’t recursively copy them. Both operations stop at the first level unless you explicitly walk deeper.
Destructuring has the identical behavior, because it’s built on the same reference semantics: const { address } = original gives you a new binding, but address still refers to the original nested object.
Deep copy in practice
A deep copy needs to recurse through every nested object and array and copy each one individually. The built-in way to do this for most everyday data is structuredClone():
const deep = structuredClone(original);
structuredClone() handles nested objects, arrays, Maps, Sets, dates, and circular references correctly, and it’s available in modern browsers and Node.js without a library. Before it existed, a common (and flawed) workaround was:
const deep = JSON.parse(JSON.stringify(original));
This works for plain data — strings, numbers, plain objects, and arrays — but silently drops or mangles anything JSON can’t represent: functions disappear entirely, Date objects become strings, undefined values vanish, and circular references throw. It’s a pattern worth recognizing in old code, but there’s no reason to reach for it now that structuredClone() exists.
For objects containing functions, class instances, or other non-serializable values, neither structuredClone() nor the JSON trick will fully preserve behavior — those cases typically need a manual recursive copy function or a library designed for it.
Shallow vs deep, side by side
| Shallow copy | Deep copy | |
|---|---|---|
| Top-level properties | New, independent values | New, independent values |
| Nested objects/arrays | Shared reference with original | New, independent values |
| Typical tools | Spread (...), Object.assign() | structuredClone() |
| Speed | Fast — one level of work | Slower — recurses through every level |
| Handles circular references | N/A (doesn’t recurse) | Yes, with structuredClone() |
| Common failure mode | Mutating a nested value affects the original | Functions/class instances may not survive |
When a shallow copy is exactly what you want
Deep copying isn’t automatically “safer” — it’s slower, and for flat data structures it does unnecessary work. A shallow copy is the right tool whenever you’re only reassigning top-level properties, which is extremely common in patterns like React state updates: setState({ ...state, count: state.count + 1 }) only needs a new top-level object; it doesn’t need to duplicate everything nested inside state that wasn’t touched. Reaching for a deep copy by default, out of caution, adds real overhead in exactly the code paths — frequent, small state updates — where that overhead compounds the most.
The failure mode to watch for is the opposite: shallow-copying an object with nested state and then mutating a nested field directly, expecting the original to be unaffected. If your data has meaningful nesting and you intend to mutate the copy independently, that’s the signal you need either a deep copy or an update pattern that spreads at every level you intend to change, not just the top one.
Arrays of objects need the same care
The same trap shows up constantly with arrays, since an array holding objects is just a list of references. const copy = [...originalArray] gives you a new array — pushing or removing items from copy never touches originalArray — but each element inside it is still the same object reference as before. Mapping over the array and spreading each element, originalArray.map(item => ({ ...item })), shallow-copies one level deeper, which is often exactly enough for a flat list of simple records but still won’t help if those records themselves contain nested objects.
The takeaway
A shallow copy is fast and correct for flat data or for updates that only touch top-level fields; a deep copy is necessary the moment you need full independence from an original object’s nested structure. Reach for the spread operator or Object.assign() by default, and reach for structuredClone() specifically when you know you’ll mutate nested data and can’t afford that mutation leaking back into the source object.
Tagged
Keep reading
Takina · · 4 min read Web Locks API: Coordinating Work Across Browser Tabs
The Web Locks API lets JavaScript acquire named locks shared across tabs, so only one tab does work like a token refresh or a write at a time.
Takina · · 4 min read Svelte 5 Runes Explained
Svelte 5 runes like $state and $derived replace the old reactive-assignment magic with explicit function calls that work anywhere in a file.
Takina · · 4 min read Canvas vs SVG: Choosing the Right Graphics API
Canvas draws immediate-mode pixels you redraw yourself; SVG keeps a live, styleable DOM of vector shapes. Here's how to pick between the two.