Skip to main content

Shallow copy vs deep copy in JavaScript

Shallow copy creates a new object with copies of top-level properties, but nested objects stay shared. Deep copy clones the full structure at every level so nothing is shared between the original and the clone.

Theory

TL;DR

  • Shallow copy is like photocopying a letter with attached photos: the letter is new, but both copies point to the same photos
  • Deep copy photocopies the photos too - nothing shared
  • { ...obj } and Object.assign() are shallow; structuredClone() and lodash.cloneDeep() are deep
  • Flat object with no nesting? Shallow is enough. Nested state or API payloads? Use deep.
  • structuredClone() is the modern default for deep copying, available in all browsers since 2022 and Node.js 17+

Quick example

javascript
const original = { name: 'Alice', scores: [90, 85] }; const shallow = { ...original }; // shallow copy const deep = structuredClone(original); // deep copy shallow.name = 'Bob'; shallow.scores[0] = 100; console.log(original.name); // 'Alice' - string copied by value console.log(original.scores[0]); // 100 - array is shared! console.log(deep.scores[0]); // 90 - fully independent

shallow.name and original.name are independent because strings are primitives copied by value. But shallow.scores and original.scores point to the same array in memory, so any mutation on one affects the other.

Key difference

Shallow copy allocates a new object and iterates enumerable own properties. Primitives (strings, numbers, booleans) are copied by value. Objects and arrays at any deeper level are assigned by reference - both the original and the copy point to the same memory location. Deep copy traverses the full structure recursively, creating new objects and arrays at every level so the two structures share nothing.

When to use

  • Config object with no nested data - shallow with { ...obj } or Object.assign
  • React component state with nested fields like address.city or coords[] - deep copy to isolate updates and avoid stale references
  • API response you cache and then modify - structuredClone keeps the cache intact
  • Performance-critical loops where nested data is read-only - shallow wins here (top-level iteration vs full recursive traversal)

Comparison table

MethodTypeFunctionsDateCircular refsBest for
{ ...obj }ShallowkeptkeptN/AFlat objects, React state spreads
Object.assign()ShallowkeptkeptN/AMerging flat configs
structuredClone()DeepthrowspreservedhandledModern apps, Node 17+, Worker messages
JSON.parse/stringifyDeepstrippedbecomes stringthrowsSimple objects with no special types
lodash.cloneDeep()DeepclonedpreservedhandledLegacy codebases, complex nested data

How V8 handles this

V8 represents objects as hash maps with property descriptors and pointers. The spread operator iterates enumerable own properties via the [[GetOwnProperty]] internal method, copies primitive values directly, and reassigns pointers for nested objects without touching them. structuredClone() uses the HTML Structured Clone Algorithm: it traverses the full tree, creates new objects at every level, and tracks circular references with an internal map. That same tracking is why it throws on functions - they are not serializable by the algorithm. lodash.cloneDeep tracks visited nodes using a stack and creates new objects via Object.create(null), making it the most permissive option for edge cases.

Common mistakes

1. Assuming spread copies nested objects

javascript
const state = { user: { prefs: [1, 2] } }; const copy = { ...state }; copy.user.prefs.push(3); console.log(state.user.prefs); // [1, 2, 3] - original mutated

Spread only goes one level deep. copy.user and state.user are the same reference. Fix: const copy = structuredClone(state).

2. Using JSON.parse/stringify with Dates or functions

javascript
const obj = { name: 'Alice', created: new Date(), greet: () => 'hi' }; const copy = JSON.parse(JSON.stringify(obj)); console.log(typeof copy.created); // 'string' - Date became a string console.log(copy.greet); // undefined - function stripped

structuredClone preserves Date correctly. For functions, there is no built-in solution - use lodash.cloneDeep or handle them manually.

3. Using .slice() and thinking nesting is handled

javascript
const arr = [1, [2, 3]]; const copy = arr.slice(); // shallow - nested arrays still shared copy[1][0] = 99; console.log(arr[1][0]); // 99 - original mutated

.slice(), spread, and Array.from all create shallow copies of arrays. None of them touch nested arrays.

4. Circular references blow up JSON

javascript
const obj = { name: 'test' }; obj.self = obj; JSON.parse(JSON.stringify(obj)); // Throws: Converting circular structure to JSON structuredClone(obj); // Works fine

If your data might be self-referential, structuredClone or lodash.cloneDeep are the only safe options.

5. Non-enumerable properties disappear on spread

javascript
const obj = { a: 1 }; Object.defineProperty(obj, 'b', { value: 2, enumerable: false }); const copy = { ...obj }; console.log(copy.b); // undefined

Spread and Object.assign skip non-enumerable properties. Easy to miss when copying objects that have hidden metadata attached via defineProperty.

Real-world usage

  • React: spread for flat state updates ({ ...user, name: 'Bob' }); structuredClone for nested form data before submission
  • Redux Toolkit: createSlice deep clones initial state internally to protect the store from accidental mutations
  • next-auth.js: uses lodash.cloneDeep for session objects passed through middleware layers
  • Express: express-validator clones req.body before validation - shallow for flat bodies, deep for nested schemas
  • Node.js workers: structuredClone is the standard way to pass data to Worker threads without shared memory

Follow-up questions

Q: What prints here? const a = [1]; const b = [a]; const c = [...b]; c[0][0] = 99; console.log(a[0]);
A: 99. Spreading b is shallow, so c[0] and a are the same array. The mutation goes all the way back to a.

Q: What does structuredClone do with functions?
A: It throws a DataCloneError. Functions are not serializable by the Structured Clone Algorithm. Use lodash.cloneDeep or a custom recursive clone if the object contains functions.

Q: How does lodash.cloneDeep handle circular references?
A: It tracks visited objects using a stack. When it encounters an already-visited reference, it reuses the already-cloned version instead of recursing forever. JSON fails completely here; structuredClone handles it with an internal reference map.

Q: Is Object.assign({}, obj) different from { ...obj }?
A: For plain flat objects the result is the same. The difference: Object.assign invokes setters on the target if they exist; spread does not. Both call getters on the source.

Q: You need to pass a deeply nested 10,000-node object tree to a Web Worker. What do you use and why?
A: Pass it directly via postMessage - workers use the Structured Clone Algorithm automatically, the same one structuredClone uses. Avoid JSON round-trips because they lose type info for Dates, Maps, Sets, and ArrayBuffers. For truly large payloads, look at Transferable objects for zero-copy transfers.

Examples

Shallow copy mutation in React state

javascript
const [user, setUser] = useState({ name: 'Alice', address: { city: 'NY', coords: [40.7, -74.0] } }); // Spread creates a new top-level object, but address is still shared const updated = { ...user, name: 'Bob' }; setUser(updated); // Some other code mutates updated.address later updated.address.city = 'LA'; console.log(user.address.city); // 'LA' - the original state object was affected

This is one of the most common bugs in React apps. The spread { ...user } creates a new top-level object, but updated.address and user.address are still the same reference in memory. Any mutation to the nested object affects both.

Deep copy with structuredClone

javascript
const [user, setUser] = useState({ name: 'Alice', address: { city: 'NY', coords: [40.7, -74.0] } }); // Fully isolated copy - nothing shared const updated = structuredClone(user); updated.address.city = 'LA'; updated.address.coords[0] = 34.0; setUser(updated); console.log(user.address.city); // 'NY' - original untouched console.log(user.address.coords[0]); // 40.7 - original untouched

structuredClone traverses the full tree. Dates, Maps, Sets, and ArrayBuffers are all preserved with their correct types. The one thing to watch: if the object contains functions, it throws a DataCloneError.

Circular references and prototype chain (senior level)

javascript
const original = { name: 'test' }; original.self = original; // circular reference // JSON fails immediately try { JSON.parse(JSON.stringify(original)); } catch (e) { console.log(e.message); // Converting circular structure to JSON } // structuredClone handles it correctly const cloned = structuredClone(original); console.log(cloned !== original); // true - different object console.log(cloned.self === cloned); // true - self-reference preserved inside clone console.log(cloned.self !== original); // true - no link back to original

The circular reference in cloned points back to cloned itself, not to the original. That is correct behavior for a true deep clone. I've seen this trip up senior candidates who assumed structuredClone would fail on cycles the same way JSON does.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?