Value vs Reference in JavaScript: The Mental Model That Never Breaks
🎯 Interview-Ready Deep Dive — Why does changing one object change another? Understanding JavaScript's memory model (Stack vs Heap) is the key to bug-free code.
Value vs Reference in JavaScript: The Mental Model That Never Breaks
🎯 Interview-Ready Deep Dive — This isn’t a surface-level overview. I break down exactly how JavaScript handles memory, why certain behaviors occur, and what interviewers expect you to know. Master this, and you’ll confidently answer any question on pass-by-value, object mutation, and reference behavior.
TL;DR: JavaScript is always pass-by-value. For primitives (string, number, boolean, etc.), the value is the actual data. For objects (arrays, functions, objects), the value is a reference to the data. Mutations propagate through all references, but reassignments do not.
Quick Reference
| Concept | Behavior |
|---|---|
| Primitives | Immutable; copied by value |
| Objects | Mutable by default; variables store references |
| Mutation | Changes the object (propagates to all references) |
| Reassignment | Replaces the reference (doesn’t propagate) |
| Shallow Copy | Only top-level is copied; nested objects are shared |
| Deep Copy | All levels are duplicated; no shared state |
Table of Contents
- The One Rule You Must Remember
- Two Categories of Data
- How Memory Works: Visualized
- Reassignment vs Mutation
- Function Arguments
- Object Comparison
- Copying Objects
- Common Bugs and Fixes
- Interview Self-Check
The One Rule You Must Remember
JavaScript is always pass-by-value.
There are no exceptions.
What confuses developers is what the value actually is:
- For primitives → the value is the data itself
- For objects → the value is a reference to the data
Everything else follows from this foundational concept.
Two Categories of Data in JavaScript
Primitives (Value Types)
JavaScript has seven primitive types: string, number, bigint, boolean, undefined, symbol, and null.
Key properties:
- ✅ Immutable — cannot be changed after creation
- ✅ Copied by value — assignments create independent copies
- ✅ No shared state — changing one variable never affects another
let a = 10;
let b = a;
b = 20;
console.log(a); // 10
console.log(b); // 20
Objects (Reference Types)
Everything that isn’t a primitive is an object: {}, [], functions, Date, Map, Set, class instances.
const obj1 = { count: 1 };
const obj2 = obj1;
obj2.count = 2;
console.log(obj1.count); // 2
Both variables point to the same object in memory — mutating it affects all references.
How Memory Works: Visualized
Understanding where data lives in memory is the key to mastering this concept.
Primitives: Each Variable Gets Its Own Copy
flowchart LR
subgraph Stack["Stack Memory"]
A["a = 10"]
B["b = 10"]
end
style A fill:#4ade80,stroke:#166534,color:#000
style B fill:#4ade80,stroke:#166534,color:#000
When you assign let b = a, the value 10 is copied. Each variable holds its own independent value.
let a = 10;
let b = a; // b gets a COPY of 10
b = 20; // Only b changes
// a is still 10
Objects: Variables Share a Reference
flowchart LR
subgraph Stack["Stack Memory"]
A["obj1 → ref#1"]
B["obj2 → ref#1"]
end
subgraph Heap["Heap Memory"]
OBJ["{ count: 1 }"]
end
A --> OBJ
B --> OBJ
style A fill:#60a5fa,stroke:#1e40af,color:#000
style B fill:#60a5fa,stroke:#1e40af,color:#000
style OBJ fill:#fbbf24,stroke:#b45309,color:#000
ℹ️ These diagrams represent a conceptual model; actual JavaScript engines may optimize memory differently.
When you assign const obj2 = obj1, only the reference is copied — both variables point to the same object.
const obj1 = { count: 1 };
const obj2 = obj1; // obj2 gets a COPY of the reference
obj2.count = 2; // Mutates the shared object
// obj1.count is now 2
Reassignment vs Mutation (Critical Distinction)
This is where most confusion happens. Let’s visualize both scenarios.
Reassignment Creates a New Reference
flowchart TB
subgraph Before["Before Reassignment"]
direction LR
A1["a → ref#1"] --> OBJ1["{ x: 1 }"]
B1["b → ref#1"] --> OBJ1
end
Before --> After
subgraph After["After: b = { x: 2 }"]
direction LR
A2["a → ref#1"] --> OBJ2["{ x: 1 }"]
B2["b → ref#2"] --> OBJ3["{ x: 2 }"]
end
style OBJ1 fill:#fbbf24,stroke:#b45309,color:#000
style OBJ2 fill:#fbbf24,stroke:#b45309,color:#000
style OBJ3 fill:#f87171,stroke:#b91c1c,color:#000
let a = { x: 1 };
let b = a;
b = { x: 2 }; // Reassignment — b points to NEW object
console.log(a.x); // 1 ← unchanged
console.log(b.x); // 2
Mutation Modifies the Shared Object
flowchart TB
subgraph Before["Before Mutation"]
direction LR
A1["a → ref#1"] --> OBJ1["{ x: 1 }"]
B1["b → ref#1"] --> OBJ1
end
Before --> After
subgraph After["After: b.x = 2"]
direction LR
A2["a → ref#1"] --> OBJ2["{ x: 2 }"]
B2["b → ref#1"] --> OBJ2
end
style OBJ1 fill:#fbbf24,stroke:#b45309,color:#000
style OBJ2 fill:#f87171,stroke:#b91c1c,color:#000
let a = { x: 1 };
let b = a;
b.x = 2; // Mutation — modifies the SAME object
console.log(a.x); // 2 ← changed!
console.log(b.x); // 2
Function Arguments: The Most Misunderstood Part
JavaScript uses call-by-sharing — the function receives a copy of the reference, not the reference itself.
Mutation Persists Outside the Function
function update(user) {
user.name = "Updated";
}
const account = { name: "Initial" };
update(account);
console.log(account.name); // "Updated"
The function mutates the same object that account references.
Reassignment Stays Local
function replace(user) {
user = { name: "New" }; // Local reassignment only
}
const account = { name: "Initial" };
replace(account);
console.log(account.name); // "Initial" ← unchanged
The local parameter user was pointed to a new object, but account still references the original.
flowchart TB
subgraph Caller["Caller Scope"]
ACC["account → ref#1"]
end
subgraph Function["Function Scope"]
PARAM["user → ref#1"]
PARAM2["user → ref#2"]
end
subgraph Heap["Heap Memory"]
OBJ1["{ name: 'Initial' }"]
OBJ2["{ name: 'New' }"]
end
ACC --> OBJ1
PARAM -.->|"before"| OBJ1
PARAM2 -->|"after reassignment"| OBJ2
style OBJ1 fill:#4ade80,stroke:#166534,color:#000
style OBJ2 fill:#f87171,stroke:#b91c1c,color:#000
Object Comparison: Identity Matters
{} === {} // false
[] === [] // false
Each literal creates a new object with a unique reference. JavaScript compares objects by identity (reference), not by structure.
const a = {};
const b = a;
a === b; // true ← same reference
Copying Objects: Shallow vs Deep
Shallow Copy (Spread Operator)
const original = { nested: { value: 1 } };
const copy = { ...original };
copy.nested.value = 99;
console.log(original.nested.value); // 99 ← also changed!
flowchart LR
subgraph Variables
ORIG["original"]
COPY["copy"]
end
subgraph TopLevel["Top Level (Copied)"]
O1["{ nested: → }"]
O2["{ nested: → }"]
end
subgraph Nested["Nested (Shared!)"]
N1["{ value: 1 }"]
end
ORIG --> O1
COPY --> O2
O1 --> N1
O2 --> N1
style N1 fill:#f87171,stroke:#b91c1c,color:#000
Deep Copy (structuredClone)
⚠️ Availability note:
UsestructuredClone()for deep copies. It is available in modern browsers and Node.js v17+.
const original = { nested: { value: 1 } };
const copy = structuredClone(original);
copy.nested.value = 99;
console.log(original.nested.value); // 1 ← unchanged
flowchart LR
subgraph Variables
ORIG["original"]
COPY["copy"]
end
subgraph Original_Tree["Original Tree"]
O1["{ nested: → }"]
N1["{ value: 1 }"]
end
subgraph Cloned_Tree["Cloned Tree"]
O2["{ nested: → }"]
N2["{ value: 1 }"]
end
ORIG --> O1
O1 --> N1
COPY --> O2
O2 --> N2
style N1 fill:#4ade80,stroke:#166534,color:#000
style N2 fill:#4ade80,stroke:#166534,color:#000
Common Bugs Caused by Misunderstanding References
Bug 1: Mutating Shared Default Values
// ❌ BAD
const defaultConfig = { timeout: 5000 };
function setup(config = defaultConfig) {
config.timeout = 10000; // Mutates defaultConfig globally!
}
// ✅ FIX
function setup(config = {}) {
const local = { ...defaultConfig, ...config };
}
Bug 2: Assuming Spread Is a Deep Copy
// ❌ BAD
const copy = { ...obj };
copy.nested.value = 1; // Mutates original!
// ✅ FIX
const copy = structuredClone(obj);
How to Prevent Accidental Mutations
Defensive Deep Copy
function process(data) {
const local = structuredClone(data);
// Safe to modify
return local;
}
Object.freeze (Shallow Only)
const config = Object.freeze({ timeout: 5000 });
config.timeout = 10000; // Silently fails (throws `TypeError` in strict mode)
⚠️
Object.freeze()is shallow — nested objects must be frozen separately.
The Correct Mental Model (Summary)
flowchart TD
START["Variable Assignment"] --> CHECK{"What type?"}
CHECK -->|"Primitive"| PRIM["Value is COPIED"]
CHECK -->|"Object"| OBJ["Reference is COPIED"]
PRIM --> PRIM_RESULT["Independent copies<br/>No shared state"]
OBJ --> OBJ_CHECK{"What operation?"}
OBJ_CHECK -->|"Mutation"| MUT["Changes propagate<br/>to all references"]
OBJ_CHECK -->|"Reassignment"| REASS["Only local variable<br/>is affected"]
style PRIM fill:#4ade80,stroke:#166534,color:#000
style MUT fill:#f87171,stroke:#b91c1c,color:#000
style REASS fill:#60a5fa,stroke:#1e40af,color:#000
Remember:
- Primitives → copied by value
- Objects → referenced by value
- Mutations → propagate
- Reassignments → don’t propagate
- JavaScript → always pass-by-value
Interview Self-Check
Test your understanding — try answering each question before revealing the answer.
1. Why does {} !== {} return true?
Reveal Answer
Each {} literal creates a new object in memory with its own unique reference. JavaScript compares objects by identity (reference), not by structure. Since these are two different objects at two different memory addresses, they are not equal.
const a = {};
const b = {};
a === b // false — different references
const c = a;
a === c // true — same reference
2. Why does mutation escape function scope but reassignment doesn’t?
Reveal Answer
When you pass an object to a function, the function receives a copy of the reference — not the object itself, and not the original reference variable.
- Mutation modifies the object that both references point to → changes persist
- Reassignment only changes what the local copy points to → caller’s variable is unaffected
function mutate(obj) { obj.x = 2; } // Modifies shared object ✓
function reassign(obj) { obj = {x: 2}; } // Only changes local copy ✗
3. Why does the spread operator only create a shallow copy?
Reveal Answer
The spread operator {...obj} copies each property’s value into a new object. For primitive properties, this creates independent copies. But for nested objects, the “value” being copied is a reference — so both the original and the copy point to the same nested object.
const original = { nested: { x: 1 } };
const copy = { ...original };
// copy.nested === original.nested (same reference!)
Use structuredClone() for deep copies.
4. Why is JavaScript not pass-by-reference?
Reveal Answer
In true pass-by-reference (like C++ with &), the function receives the actual variable from the caller — reassigning it would change the caller’s variable.
JavaScript is pass-by-value. For objects, the value passed is a reference, but it’s a copy of that reference. Reassigning the parameter creates a new local binding; it cannot affect the caller’s variable.
function tryToReplace(arr) {
arr = [1, 2, 3]; // Only reassigns local parameter
}
const myArray = [];
tryToReplace(myArray);
console.log(myArray); // [] — unchanged (this wouldn't happen with true pass-by-reference)
5. Why can two variables affect the same object?
Reveal Answer
Variables don’t store objects — they store references to objects. When you assign one variable to another (const b = a), you copy the reference, not the object. Both variables now point to the same object in memory, so mutating through either variable affects the shared object.
const a = { count: 0 };
const b = a; // b receives a copy of the reference
b.count++;
console.log(a.count); // 1 — same object
Closing Thoughts
This concept is foundational. Closures, immutability, state management, performance optimization, and Node.js memory behavior all depend on it.
If your mental model here is correct, everything else builds cleanly.