JavaScript: Deep Copy vs. Shallow Copy Explained

By Maulik Paghdal

07 Dec, 2024

•  9 minutes to Read

JavaScript: Deep Copy vs. Shallow Copy Explained

Introduction

Object and array copying sits at the heart of many JavaScript applications, yet it remains one of the most misunderstood concepts among developers. The distinction between shallow and deep copying isn't just academic-it's the difference between predictable code and mysterious bugs that appear weeks after deployment.

When you copy data structures in JavaScript, you're not just duplicating values. You're making decisions about memory references, performance trade-offs, and data integrity. Understanding these mechanics will fundamentally change how you approach state management, data manipulation, and debugging in your applications.


What is a Shallow Copy?

A shallow copy creates a new object or array, but only duplicates the top-level properties. Any nested objects or arrays remain connected to the original through shared references. Think of it as photocopying a document that contains business cards-you get a new document, but the business cards inside are still the same physical objects.

The Reference Problem in Action

const userProfile = {
  name: "Sarah",
  preferences: {
    theme: "dark",
    notifications: true
  },
  tags: ["developer", "javascript"]
};

const profileCopy = { ...userProfile };

// This works as expected
profileCopy.name = "Sarah Johnson";
console.log(userProfile.name); // Still "Sarah"

// This causes problems
profileCopy.preferences.theme = "light";
console.log(userProfile.preferences.theme); // Now "light" - original changed!

profileCopy.tags.push("react");
console.log(userProfile.tags); // Now includes "react"

The issue becomes clear when you realize that preferences and tags are references, not values. Both the original and the copy point to the same memory locations for these nested structures.

Shallow Copy Methods and Their Trade-offs

1. Spread Operator (...)

The spread operator offers clean, readable syntax and works well for objects and arrays:

// Objects
const objCopy = { ...originalObj };

// Arrays
const arrCopy = [...originalArray];

// Combining with new properties
const enhancedUser = { ...user, lastLogin: new Date() };

💡 Tip: The spread operator is generally preferred for its readability and ES6+ compatibility.

2. Object.assign()

More explicit but slightly more verbose:

const objCopy = Object.assign({}, originalObj);

// Can merge multiple objects
const combined = Object.assign({}, defaults, userSettings, overrides);

⚠️ Warning: Object.assign() mutates the first argument. Always pass an empty object {} as the first parameter unless you intentionally want to modify an existing object.

3. Array-specific Methods

// slice() creates a shallow copy
const arrCopy = originalArray.slice();

// Array.from() for array-like objects
const arrCopy = Array.from(originalArray);

When Shallow Copy Works Perfectly

Shallow copying shines when dealing with flat data structures:

const apiResponse = {
  status: 200,
  message: "Success",
  timestamp: 1642598400000,
  userId: 12345
};

const processedResponse = { ...apiResponse, processed: true };
// Safe because all properties are primitive values

📌 Note: Primitive values (strings, numbers, booleans, null, undefined, symbols) are always copied by value, not reference.


What is a Deep Copy?

Deep copying creates a completely independent clone where every level of nesting gets duplicated. Changes to nested properties in the copy won't affect the original, and vice versa. It's like creating an entirely separate universe for your data.

The Independence Factor

const complexUser = {
  name: "Alex",
  settings: {
    display: {
      theme: "dark",
      fontSize: 16
    },
    privacy: {
      shareData: false,
      cookies: true
    }
  },
  projects: [
    { name: "Website", status: "active" },
    { name: "Mobile App", status: "pending" }
  ]
};

const independentCopy = JSON.parse(JSON.stringify(complexUser));

// Now we can modify nested properties safely
independentCopy.settings.display.theme = "light";
independentCopy.projects[0].status = "completed";

console.log(complexUser.settings.display.theme); // Still "dark"
console.log(complexUser.projects[0].status); // Still "active"

Deep Copy Methods and Their Limitations

1. JSON.parse(JSON.stringify()) - The Quick Solution

This approach works well for simple cases but has significant limitations:

const deepCopy = JSON.parse(JSON.stringify(original));

Limitations:

  • Functions are lost
  • undefined values become null or disappear
  • Dates become strings
  • RegExp objects become empty objects
  • Circular references cause errors
const problematicObject = {
  fn: () => console.log("Hello"),
  date: new Date(),
  regex: /test/g,
  undef: undefined
};

const copy = JSON.parse(JSON.stringify(problematicObject));
console.log(copy);
// { date: "2024-01-01T00:00:00.000Z", regex: {} }
// fn and undef are gone!

2. Lodash's cloneDeep - The Reliable Choice

const _ = require("lodash");
const deepCopy = _.cloneDeep(original);

Lodash handles edge cases that JSON methods miss:

const complexObject = {
  fn: function() { return "Hello"; },
  date: new Date(),
  regex: /test/gi,
  nested: { deeply: { nested: { value: 42 } } }
};

const lodashCopy = _.cloneDeep(complexObject);
console.log(lodashCopy.fn()); // "Hello" - function preserved
console.log(lodashCopy.date instanceof Date); // true

3. Custom Recursive Implementation

For specific needs or to avoid dependencies, you can build your own:

function deepClone(obj, visited = new WeakMap()) {
  // Handle primitives and null
  if (obj === null || typeof obj !== "object") {
    return obj;
  }
  
  // Handle circular references
  if (visited.has(obj)) {
    return visited.get(obj);
  }
  
  // Handle Date objects
  if (obj instanceof Date) {
    return new Date(obj.getTime());
  }
  
  // Handle RegExp objects
  if (obj instanceof RegExp) {
    return new RegExp(obj);
  }
  
  // Handle Arrays
  if (Array.isArray(obj)) {
    const arrCopy = [];
    visited.set(obj, arrCopy);
    for (let i = 0; i < obj.length; i++) {
      arrCopy[i] = deepClone(obj[i], visited);
    }
    return arrCopy;
  }
  
  // Handle Objects
  const objCopy = {};
  visited.set(obj, objCopy);
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      objCopy[key] = deepClone(obj[key], visited);
    }
  }
  
  return objCopy;
}

4. Modern Browser Solution: structuredClone()

Available in modern browsers (2022+), this native method handles most edge cases:

const deepCopy = structuredClone(original);

⚠️ Browser Support Warning: structuredClone() isn't supported in older browsers. Check compatibility before using in production.


Comprehensive Comparison: When and Why

AspectShallow CopyDeep Copy
Nested objects affected?Yes - references sharedNo - completely independent
PerformanceFast - only copies referencesSlower - recursively copies everything
Memory usageEfficient - shares nested dataHigher - duplicates all data
Best forFlat objects, performance-critical codeComplex nested structures
Common methods{...obj}, Object.assign()JSON.parse(JSON.stringify()), _.cloneDeep()
Risk levelHigh - unexpected mutationsLow - complete isolation
Use in React/VueState updates, props spreadingManaging complex state trees

Performance Considerations

The performance difference becomes significant with large, deeply nested objects:

// Timing shallow vs deep copy
const largeObject = {
  // ... imagine 1000+ nested properties
};

console.time("Shallow Copy");
const shallow = { ...largeObject };
console.timeEnd("Shallow Copy"); // ~0.1ms

console.time("Deep Copy");
const deep = JSON.parse(JSON.stringify(largeObject));
console.timeEnd("Deep Copy"); // ~50ms

💡 Performance Tip: If you frequently need to deep copy the same object structure, consider creating a factory function or using object pooling techniques.

Memory Implications

const baseUser = {
  settings: { theme: "dark", lang: "en" },
  history: new Array(1000).fill({ action: "click", timestamp: Date.now() })
};

// Shallow copies share the heavy arrays
const user1 = { ...baseUser };
const user2 = { ...baseUser };
// Only 1 copy of history array in memory

// Deep copies duplicate everything
const user3 = _.cloneDeep(baseUser);
const user4 = _.cloneDeep(baseUser);
// 3 separate copies of history array in memory

Real-World Application Scenarios

React State Management

// Shallow copy for updating state
const updateUserProfile = (updates) => {
  setUser(prevUser => ({
    ...prevUser,
    ...updates,
    // Keep nested objects intact unless specifically updating them
    preferences: {
      ...prevUser.preferences,
      ...updates.preferences
    }
  }));
};

API Data Transformation

// Deep copy when transforming API responses
const processApiData = (rawData) => {
  const processedData = _.cloneDeep(rawData);
  
  // Safe to mutate without affecting cache
  processedData.items.forEach(item => {
    item.displayName = item.firstName + ' ' + item.lastName;
    item.isActive = item.status === 'active';
  });
  
  return processedData;
};

Debugging and Testing

// Create test data variations without pollution
const baseTestUser = {
  id: 1,
  profile: { name: "Test User" },
  permissions: ["read", "write"]
};

// Each test gets independent data
const testScenario1 = _.cloneDeep(baseTestUser);
testScenario1.permissions.push("admin");

const testScenario2 = _.cloneDeep(baseTestUser);
testScenario2.profile.name = "Admin User";

Common Pitfalls and How to Avoid Them

The "It Worked in Development" Problem

// This might work with simple objects
const config = { apiUrl: "localhost:3000", timeout: 5000 };
const prodConfig = { ...config, apiUrl: "api.production.com" };

// But fails with nested configuration
const complexConfig = {
  api: { url: "localhost:3000", retries: 3 },
  features: { newUI: true, analytics: false }
};

const prodComplexConfig = { ...complexConfig };
prodComplexConfig.api.url = "api.production.com";
// BUG: This changes the original complexConfig too!

Solution: Always map out your data structure's nesting levels before choosing a copy method.

The Circular Reference Trap

const userA = { name: "Alice" };
const userB = { name: "Bob" };
userA.friend = userB;
userB.friend = userA; // Circular reference

// This will throw an error
try {
  const copy = JSON.parse(JSON.stringify(userA));
} catch (error) {
  console.log("Converting circular structure to JSON");
}

// Use libraries that handle circular references
const safeCopy = _.cloneDeep(userA); // Works fine

The Date Object Surprise

const event = {
  name: "Conference",
  date: new Date("2024-06-15"),
  attendees: 150
};

const jsonCopy = JSON.parse(JSON.stringify(event));
console.log(typeof jsonCopy.date); // "string" - not Date object!
console.log(jsonCopy.date.getFullYear()); // Error: getFullYear is not a function

Solution: Be aware of object types that don't survive JSON serialization.


Best Practices and Decision Framework

Choosing the Right Method

  1. For simple, flat objects: Use spread operator {...obj}
  2. For objects with one level of nesting: Use shallow copy with manual nested spreading
  3. For complex, deeply nested objects: Use _.cloneDeep() or structuredClone()
  4. For performance-critical operations: Consider if you really need copying at all

Creating a Copy Utility

// Adaptive copying utility
function smartCopy(obj, deep = false) {
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }
  
  if (!deep && isShallowStructure(obj)) {
    return Array.isArray(obj) ? [...obj] : {...obj};
  }
  
  // Use the most appropriate deep copy method
  if (typeof structuredClone !== 'undefined') {
    try {
      return structuredClone(obj);
    } catch (e) {
      // Fall back to other methods
    }
  }
  
  return _.cloneDeep ? _.cloneDeep(obj) : JSON.parse(JSON.stringify(obj));
}

function isShallowStructure(obj) {
  return Object.values(obj).every(value => 
    value === null || typeof value !== 'object'
  );
}

Conclusion

The choice between shallow and deep copying isn't just about preventing bugs-it's about understanding data flow, performance implications, and the long-term maintainability of your code. Shallow copies offer speed and memory efficiency for simple structures, while deep copies provide safety and independence for complex data.

The key is recognizing which scenario you're in. Most applications use a combination of both: shallow copying for performance-critical paths and simple updates, deep copying when data integrity and isolation are paramount.

As JavaScript continues to evolve, native solutions like structuredClone() are becoming available, but the fundamental concepts remain the same. Master these patterns, and you'll write more predictable, maintainable code that behaves exactly as intended.

Remember: every copy operation is a conscious decision about how data should behave in your application. Choose wisely.

Topics Covered

About Author

I'm Maulik Paghdal, the founder of Script Binary and a passionate full-stack web developer. I have a strong foundation in both frontend and backend development, specializing in building dynamic, responsive web applications using Laravel, Vue.js, and React.js. With expertise in Tailwind CSS and Bootstrap, I focus on creating clean, efficient, and scalable solutions that enhance user experiences and optimize performance.