How to Understand Object References and Copying in JavaScript
When you start working with objects in JavaScript, one of the most confusing and bug-producing topics is how objects are stored, assigned, and copied. Unlike primitive values, objects behave very differently when you assign them to variables, pass them to functions, or try to duplicate them. Misunderstanding this behavior leads to some of the most common and hardest-to-debug issues in JavaScript.
This guide walks you through the memory model behind primitives and objects, explains why two variables can point to the same object, and shows you every technique available for copying objects, from shallow to deep.
Primitive vs. Reference Types: The Memory Model
JavaScript stores primitive values and objects in fundamentally different ways. Understanding this difference is the key to everything that follows.
How Primitives Are Stored
When you assign a primitive value (string, number, boolean, null, undefined, symbol, bigint) to a variable, the actual value is stored directly in the variable's memory slot. When you copy it to another variable, a completely independent copy of that value is created.
let name = "Alice";
let copy = name;
copy = "Bob";
console.log(name); // "Alice" (untouched)
console.log(copy); // "Bob"
Each variable holds its own independent value. Changing one has absolutely no effect on the other.
How Objects Are Stored
Objects work differently. When you create an object, JavaScript stores the object somewhere in memory (the heap), and the variable receives a reference (think of it as an address or a pointer) to that location, not the object itself.
let user = { name: "Alice", age: 25 };
Here, user does not "contain" the object. It contains a reference that points to where { name: "Alice", age: 25 } lives in memory.
You can visualize it like this:
Variable Memory (Heap)
-------- -------------
user ------> { name: "Alice", age: 25 }
A primitive variable is like a sticky note with a value written on it. An object variable is like a sticky note with an address written on it. The address points to a house (the object). Multiple sticky notes can have the same address.
Reference Assignment: Two Variables, One Object
When you assign an object variable to another variable, you are copying the reference, not the object. Both variables now point to the exact same object in memory.
let user = { name: "Alice", age: 25 };
let admin = user;
console.log(admin.name); // "Alice"
Both user and admin point to the same object:
Variable Memory (Heap)
-------- -------------
user ------> { name: "Alice", age: 25 }
admin ----/
This means that modifying the object through one variable is immediately visible through the other:
let user = { name: "Alice", age: 25 };
let admin = user;
admin.name = "Bob";
console.log(user.name); // "Bob" (changed through admin, visible through user)
This is not a bug. Both variables reference the same single object. There is only one object in memory, and two ways to access it.
Function Arguments Work the Same Way
When you pass an object to a function, the function receives a copy of the reference, not a copy of the object. This means the function can modify the original object:
function updateAge(obj) {
obj.age = 30;
}
let user = { name: "Alice", age: 25 };
updateAge(user);
console.log(user.age); // 30 (the original object was modified)
This is one of the most common sources of unexpected bugs. If you pass an object to a function and the function modifies it, the caller's object changes too.
Comparison by Reference vs. by Value
Understanding how JavaScript compares primitives and objects is critical.
Primitives Are Compared by Value
let a = "hello";
let b = "hello";
console.log(a === b); // true (same value)
Objects Are Compared by Reference
Two objects are equal only if they are the exact same object in memory (same reference). Two different objects with identical contents are not equal.
let obj1 = { name: "Alice" };
let obj2 = { name: "Alice" };
console.log(obj1 === obj2); // false (different objects in memory)
console.log(obj1 == obj2); // false (still different references)
Even though obj1 and obj2 have identical properties, they are two separate objects stored at different memory locations.
However, if two variables point to the same object:
let obj1 = { name: "Alice" };
let obj2 = obj1;
console.log(obj1 === obj2); // true (same reference)
There is no built-in operator in JavaScript that compares objects by their contents. You need to write your own deep comparison function or use a library.
Shallow Copy: Object.assign() and the Spread Operator
When you need an independent copy of an object so that changes to one don't affect the other, you need to clone it. The simplest approach is a shallow copy.
A shallow copy creates a new object and copies all top-level properties from the original. If a property's value is a primitive, the value itself is copied. If a property's value is an object (nested object, array, etc.), only the reference is copied.
Using Object.assign()
Object.assign(target, ...sources) copies all enumerable own properties from one or more source objects into a target object.
let user = { name: "Alice", age: 25 };
let clone = Object.assign({}, user);
clone.name = "Bob";
console.log(user.name); // "Alice" (original is safe)
console.log(clone.name); // "Bob"
You can also merge multiple objects:
let defaults = { theme: "light", lang: "en" };
let userPrefs = { theme: "dark" };
let settings = Object.assign({}, defaults, userPrefs);
console.log(settings); // { theme: "dark", lang: "en" }
Using the Spread Operator {...obj}
The spread syntax is the modern, more concise way to create shallow copies:
let user = { name: "Alice", age: 25 };
let clone = { ...user };
clone.name = "Bob";
console.log(user.name); // "Alice"
console.log(clone.name); // "Bob"
You can also add or override properties while spreading:
let user = { name: "Alice", age: 25 };
let updated = { ...user, age: 30, role: "admin" };
console.log(updated); // { name: "Alice", age: 30, role: "admin" }
Shallow Copy for Arrays
The same concept applies to arrays. Arrays are objects, and the spread operator or Array.from() creates a shallow copy:
let original = [1, 2, 3];
let copy = [...original];
copy.push(4);
console.log(original); // [1, 2, 3] (untouched)
console.log(copy); // [1, 2, 3, 4]
The Limitation of Shallow Copy
Shallow copies only go one level deep. If your object has nested objects, the nested references are shared between the original and the copy. This is where most bugs occur, and we will address it in detail below.
Deep Copy: structuredClone(), JSON Method, and Limitations
When your object contains nested objects, arrays, or other complex structures, you need a deep copy, a clone where every level of nesting is independently duplicated.
The structuredClone() Method (Recommended)
Introduced as a globally available function, structuredClone() is the modern, built-in way to perform deep cloning in JavaScript.
let user = {
name: "Alice",
address: {
city: "Rome",
zip: "00100"
},
hobbies: ["reading", "coding"]
};
let clone = structuredClone(user);
clone.address.city = "Milan";
clone.hobbies.push("gaming");
console.log(user.address.city); // "Rome" (original untouched)
console.log(user.hobbies); // ["reading", "coding"] (original untouched)
console.log(clone.address.city); // "Milan"
console.log(clone.hobbies); // ["reading", "coding", "gaming"]
structuredClone() handles:
- Nested objects and arrays
DateobjectsMap,SetArrayBuffer,Blob,File,ImageDataRegExp- Circular references (objects that reference themselves)
let obj = {};
obj.self = obj; // circular reference
let clone = structuredClone(obj);
console.log(clone.self === clone); // true (circular ref preserved correctly)
What structuredClone() cannot clone:
- Functions
- DOM elements
Symbolproperties- Prototype chain (the clone will be a plain object)
Errorobjects (limited support)
let obj = {
greet: function() { return "hello"; }
};
// This will throw a DataCloneError
structuredClone(obj);
structuredClone() is available in all modern browsers and Node.js 17+. For most use cases, it is the best choice for deep cloning.
The JSON Method: JSON.parse(JSON.stringify())
Before structuredClone() existed, the most common deep cloning trick was the JSON round-trip:
let user = {
name: "Alice",
address: {
city: "Rome",
zip: "00100"
}
};
let clone = JSON.parse(JSON.stringify(user));
clone.address.city = "Milan";
console.log(user.address.city); // "Rome"
console.log(clone.address.city); // "Milan"
This works by converting the object to a JSON string and then parsing it back into a new object. It creates a fully independent deep copy.
However, the JSON method has serious limitations:
| What Gets Lost | Reason |
|---|---|
undefined values | Not valid in JSON |
| Functions | Not valid in JSON |
Symbol properties | Not valid in JSON |
Date objects | Converted to strings, not restored as Date |
Map, Set | Converted to {} |
RegExp | Converted to {} |
Infinity, NaN | Converted to null |
| Circular references | Throws an error |
let obj = {
date: new Date("2024-01-01"),
regex: /hello/gi,
fn: function() {},
undef: undefined,
nan: NaN,
set: new Set([1, 2, 3])
};
let clone = JSON.parse(JSON.stringify(obj));
console.log(clone);
// {
// date: "2024-01-01T00:00:00.000Z", ← string, not Date
// regex: {}, ← empty object
// nan: null, ← null, not NaN
// set: {} ← empty object
// // fn and undef are gone entirely
// }
The JSON method silently drops or transforms data. Use it only when you are certain your object contains only JSON-safe types (string, number, boolean, null, plain objects, and arrays).
structuredClone() vs. JSON: Quick Comparison
| Feature | structuredClone() | JSON.parse(JSON.stringify()) |
|---|---|---|
| Nested objects/arrays | Yes | Yes |
Date objects | Yes (preserved) | No (becomes string) |
Map, Set | Yes | No |
| Circular references | Yes | Throws error |
| Functions | No | No |
undefined | Yes | No (dropped) |
NaN, Infinity | Yes | No (becomes null) |
| Performance | Generally faster | Slower for large objects |
Libraries for Deep Cloning
For complex scenarios where you need maximum flexibility, third-party libraries offer robust deep cloning utilities.
Lodash _.cloneDeep
Lodash's _.cloneDeep is one of the most widely used deep cloning utilities:
import _ from "lodash";
let user = {
name: "Alice",
address: {
city: "Rome",
coords: [41.9, 12.5]
}
};
let clone = _.cloneDeep(user);
clone.address.city = "Milan";
console.log(user.address.city); // "Rome"
You can also import only cloneDeep to reduce bundle size:
import cloneDeep from "lodash/cloneDeep";
let clone = cloneDeep(user);
When to Use a Library
- You need to clone objects with functions, custom class instances, or complex prototype chains
- You work in an environment without
structuredClone()(older Node.js versions) - You need custom cloning behavior (Lodash's
_.cloneDeepWithallows a customizer function)
For most projects, prefer structuredClone(). Reach for Lodash only when structuredClone() does not meet your requirements, such as cloning objects with functions or custom prototypes.
Common Mistake: Shallow Copy of Nested Objects
This is the single most frequent mistake developers make with object copying. Let's see it in action and understand exactly what goes wrong.
The Wrong Way
let user = {
name: "Alice",
address: {
city: "Rome",
zip: "00100"
}
};
// Shallow copy using spread
let clone = { ...user };
// Modify nested property
clone.address.city = "Milan";
console.log(user.address.city); // "Milan" (OOPS! Original changed!)
Why does this happen?
The spread operator copies properties one level deep. The name property is a string (primitive), so it gets a truly independent copy. But address is an object, so only the reference is copied. Both user.address and clone.address point to the same object in memory.
user.address ------> { city: "Milan", zip: "00100" }
clone.address ----/
This exact same problem happens with Object.assign():
let user = {
name: "Alice",
address: {
city: "Rome",
zip: "00100"
}
};
let clone = Object.assign({}, user);
clone.address.city = "Milan";
console.log(user.address.city); // "Milan" (same problem)
And with arrays containing objects:
let users = [
{ name: "Alice" },
{ name: "Bob" }
];
let copy = [...users];
copy[0].name = "Charlie";
console.log(users[0].name); // "Charlie" (original array's object was modified!)
The Correct Way
Option 1: Use structuredClone() (best for most cases)
let user = {
name: "Alice",
address: {
city: "Rome",
zip: "00100"
}
};
let clone = structuredClone(user);
clone.address.city = "Milan";
console.log(user.address.city); // "Rome" (safe!)
console.log(clone.address.city); // "Milan"
Option 2: Manual nested spread (for simple, known structures)
let user = {
name: "Alice",
address: {
city: "Rome",
zip: "00100"
}
};
let clone = {
...user,
address: { ...user.address }
};
clone.address.city = "Milan";
console.log(user.address.city); // "Rome" (safe!)
console.log(clone.address.city); // "Milan"
This manual approach works but becomes impractical for deeply nested objects. You would need to spread every nested level manually, and if the structure changes, you must update the cloning code.
Option 3: JSON round-trip (if your data is JSON-safe)
let clone = JSON.parse(JSON.stringify(user));
Option 4: Lodash _.cloneDeep (for complex scenarios)
import cloneDeep from "lodash/cloneDeep";
let clone = cloneDeep(user);
How to Spot This Bug
Watch out for these patterns in your code:
// Any of these create only a shallow copy:
let copy = { ...original };
let copy = Object.assign({}, original);
let copy = [...originalArray];
// If the original has nested objects/arrays,
// modifying nested properties on the copy WILL affect the original.
If your object has any nesting (objects inside objects, arrays of objects, etc.), a shallow copy is not enough. Use structuredClone() or a library for a true independent clone.
Quick Reference: Choosing the Right Copy Method
| Scenario | Recommended Method |
|---|---|
| Flat object (no nesting) | { ...obj } or Object.assign({}, obj) |
| Nested object, JSON-safe data | structuredClone() |
Object with Date, Map, Set | structuredClone() |
| Object with circular references | structuredClone() |
| Object with functions or class instances | _.cloneDeep() from Lodash |
| Quick and dirty (JSON-safe only) | JSON.parse(JSON.stringify(obj)) |
| Simple array of primitives | [...arr] or Array.from(arr) |
| Array of objects | structuredClone(arr) |
Understanding how references and copying work in JavaScript is fundamental to writing predictable, bug-free code. The key takeaway is simple: primitives are copied by value, objects are copied by reference. Whenever you need a true independent copy of an object, be intentional about whether a shallow or deep copy is appropriate, and choose the right tool for the job.