DEV Community

Manoj Khatri
Manoj Khatri

Posted on

Why Your JavaScript Objects Keep Changing (And It's Not a Bug)

const user = { name: "Alice", role: "user" };
const admin = user;

admin.role = "admin";

console.log(user.role); // What do you think this logs?
Enter fullscreen mode Exit fullscreen mode

If you guessed "user", welcome to the club. Most developers get this wrong at first.

The answer is "admin". And if that surprises you, you're about to learn something that will change how you write JavaScript forever.

This isn't some edge case or weird quirk. This is fundamental to how JavaScript works. Once you understand what's happening behind the scenes in something called "the stack" and "the heap" suddenly everything clicks.

Those bugs that seemed random? They make sense. Those React state issues? Crystal clear. Those confusing code reviews? Actually straightforward.

Let me show you the mental model that most tutorials skip.

The Code That Doesn't Make Sense

Here's something you might have written (or are about to write):

function createUser(template) {
  const newUser = template;
  newUser.id = generateId();
  return newUser;
}

const userTemplate = { name: "", role: "user", permissions: [] };

const alice = createUser(userTemplate);
alice.name = "Alice";

const bob = createUser(userTemplate);
bob.name = "Bob";

console.log(alice.name); // "Bob" ??? 😱
Enter fullscreen mode Exit fullscreen mode

Wait, what? Alice's name changed when we created Bob?

This looks like a bug. The code seems perfectly logical. But it's working exactly as designed, we just don't have the right mental model yet.

The problem isn't the code. It's that objects live in a completely different place than we think.

The Mental Model You Actually Need

Here's what's really happening when JavaScript runs your code:

Think of JavaScript Memory Like a Restaurant

The Stack = The Order Board

  • Fast, temporary, organized
  • New orders go on top, completed orders get removed
  • Automatically cleared when done
  • Holds: function calls, variables, and pointers

The Heap = The Storage Room

  • Slower, permanent (until garbage collected), flexible
  • Stores the actual data
  • Accessed by reference (like a locker number)
  • Holds: objects, arrays, functions, most strings

When you write const user = { name: "Alice" }, here's what JavaScript does:

STACK                    HEAP
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ user: ──────┼────────>β”‚ {                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜         β”‚   name: "Alice"  β”‚
                        β”‚ }                β”‚
                        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

The variable user lives on the stack and holds a pointer (memory address). The actual object lives on the heap.

This is the key insight: When you copy a variable, you're copying the pointer, not the object itself.

The Five "Bugs" That Aren't Actually Bugs

Once you understand stack vs heap, these confusing behaviors suddenly make perfect sense.

1. The Accidental Mutation

const settings = { theme: "dark", language: "en" };
const userPrefs = settings;      // Copies the POINTER, not the object
const adminPrefs = settings;     // Another copy of the SAME pointer

userPrefs.theme = "light";

console.log(adminPrefs.theme);   // "light" 😱
Enter fullscreen mode Exit fullscreen mode

What's happening in memory:

STACK                           HEAP
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ settings:    ────┼──┐        β”‚ {               β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€  β”œβ”€β”€β”€β”€β”€β”€β”€>β”‚   theme: "light"β”‚
β”‚ userPrefs:   ────┼───        β”‚   language: "en"β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€  β”‚        β”‚ }               β”‚
β”‚ adminPrefs:  β”€β”€β”€β”€β”Όβ”€β”€β”˜        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

All three variables point to the same object on the heap. When you change one, you change them all.

The fix:

const settings = { theme: "dark", language: "en" };
const userPrefs = { ...settings };    // NEW heap object
const adminPrefs = { ...settings };   // Another NEW heap object

userPrefs.theme = "light";
console.log(adminPrefs.theme);        // "dark" βœ…
Enter fullscreen mode Exit fullscreen mode

Now each variable points to its own object.

2. The Array Surprise

function addItem(list, item) {
  list.push(item);    // Mutates the original heap object
  return list;
}

const shoppingList = [1, 2, 3];
const newList = addItem(shoppingList, 4);

console.log(shoppingList);  // [1, 2, 3, 4] 😱
Enter fullscreen mode Exit fullscreen mode

You returned the list, so it should be safe, right? Nope. push mutates the heap object that both variables point to.

The fix:

function addItem(list, item) {
  return [...list, item];  // Creates NEW heap object
}

const shoppingList = [1, 2, 3];
const newList = addItem(shoppingList, 4);

console.log(shoppingList);  // [1, 2, 3] βœ…
console.log(newList);       // [1, 2, 3, 4] βœ…
Enter fullscreen mode Exit fullscreen mode

3. The Closure That Remembers

This one blows people's minds:

function createCounter() {
  let count = 0;

  return function increment() {
    count++;
    return count;
  };
}

const counter1 = createCounter();
const counter2 = createCounter();

console.log(counter1());  // 1
console.log(counter1());  // 2
console.log(counter2());  // 1  ← Why doesn't this show 3?
Enter fullscreen mode Exit fullscreen mode

Here's the beautiful part: When createCounter() finishes, normally everything on the stack gets cleared. But count is captured by the returned function, so JavaScript moves it to the heap.

After calling createCounter() twice:

HEAP
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Closure 1: { count: 2 }     β”‚ ← counter1 points here
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Closure 2: { count: 1 }     β”‚ ← counter2 points here
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

Each function gets its own heap object with its own count. They don't interfere because they're pointing to different places in memory.

4. The const Confusion

const user = { name: "Alice" };

user = { name: "Bob" };     // ❌ Error: Assignment to constant variable

user.name = "Bob";          // βœ… This works fine!
Enter fullscreen mode Exit fullscreen mode

A lot of people think const means "immutable." It doesn't.

const means you can't reassign the pointer (on the stack). But you can absolutely mutate the object (on the heap).

STACK              HEAP
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ user ──┼───────>β”‚ { name: ... }β”‚ ← You CAN change this
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
     ↑
     └── You CANNOT change this pointer
Enter fullscreen mode Exit fullscreen mode

5. The React State Mystery

If you've ever wondered why this doesn't work in React:

// ❌ This won't trigger a re-render
const [user, setUser] = useState({ name: "Alice" });

function updateName() {
  user.name = "Bob";      // Mutates heap object
  setUser(user);          // Same pointer! React sees no change
}

// βœ… This works
function updateName() {
  setUser({ ...user, name: "Bob" });  // NEW heap object, NEW pointer
}
Enter fullscreen mode Exit fullscreen mode

React compares pointers, not object contents. If the pointer hasn't changed, React assumes nothing changed. This is why immutability is so important in React, you need to create new objects to trigger updates.

Try This Right Now

Open your browser console and run this experiment:

// Experiment 1: Primitives vs Objects
let a = 5;
let b = a;
b = 10;
console.log(a);  // Predict: ___

const x = { value: 5 };
const y = x;
y.value = 10;
console.log(x.value);  // Predict: ___

// Experiment 2: Arrays
const arr1 = [1, 2, 3];
const arr2 = arr1;
const arr3 = [...arr1];

arr2.push(4);

console.log(arr1);  // Predict: ___
console.log(arr3);  // Predict: ___

// Experiment 3: The Tricky One
const original = { 
  name: "Alice", 
  address: { city: "NYC" } 
};

const copy = { ...original };
copy.address.city = "LA";

console.log(original.address.city);  // Predict: ___
Enter fullscreen mode Exit fullscreen mode

Answers:

  1. 5 (primitives copy by value - a and b are completely separate)
  2. 10 (objects share the same heap reference - x and y point to the same place)
  3. [1, 2, 3, 4] and [1, 2, 3] (spread creates a new array, but arr2 shares a reference with arr1)
  4. "LA" (gotcha! Spread only copies the top level; nested objects still share references)

That last one catches a lot of people. Spread operator does a shallow copy, not a deep copy.

When This Actually Matters: Performance

Understanding stack vs heap isn't just about preventing bugs. It can make your code significantly faster:

// 🐌 Slow: Creates 1 million heap objects
function processDataSlow(items) {
  for (let i = 0; i < 1_000_000; i++) {
    const temp = { index: i, value: items[i] };
    doSomething(temp);
  }
}

// ⚑ Fast: Reuses one heap object
function processDataFast(items) {
  const temp = { index: 0, value: null };
  for (let i = 0; i < 1_000_000; i++) {
    temp.index = i;
    temp.value = items[i];
    doSomething(temp);
  }
}
Enter fullscreen mode Exit fullscreen mode

In benchmarks, the fast version runs 3-5x faster because:

  • Fewer garbage collection pauses
  • Better CPU cache utilization
  • Less memory allocation overhead

But here's the thing: Don't prematurely optimize. Use immutability by default (it prevents bugs), and only optimize hot paths when performance actually matters.

The Mental Model Cheat Sheet

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    ASSIGNMENT vs MUTATION                    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                              β”‚
β”‚  ASSIGNMENT (changes the pointer)                           β”‚
β”‚  βœ— Not allowed with const                                   β”‚
β”‚  βœ“ Creates new stack entry                                  β”‚
β”‚                                                              β”‚
β”‚    let x = { a: 1 };                                        β”‚
β”‚    x = { a: 2 };        ← Assignment                        β”‚
β”‚                                                              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                              β”‚
β”‚  MUTATION (changes the heap object)                         β”‚
β”‚  βœ“ Allowed with const                                       β”‚
β”‚  βœ— Affects all references                                   β”‚
β”‚                                                              β”‚
β”‚    const x = { a: 1 };                                      β”‚
β”‚    x.a = 2;             ← Mutation                          β”‚
β”‚                                                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

How to Write Better Code Starting Tomorrow

1. Default to creating new objects

// Instead of mutating
user.name = "Bob";

// Create new objects
const updatedUser = { ...user, name: "Bob" };
Enter fullscreen mode Exit fullscreen mode

2. Know which array methods mutate

// ❌ These mutate the original array
push, pop, shift, unshift, splice, sort, reverse

// βœ… These return new arrays
concat, slice, map, filter, reduce, toSorted, toReversed
Enter fullscreen mode Exit fullscreen mode

3. Be careful with nested objects

// Shallow copy doesn't protect nested objects!
const copy = { ...original };

// For deep copies:
const deepCopy = structuredClone(original);  // Modern browsers
// or
const deepCopy = JSON.parse(JSON.stringify(original));  // Quick solution
Enter fullscreen mode Exit fullscreen mode

4. Use TypeScript for better safety

// TypeScript can help catch mutations
interface User {
  readonly name: string;
  readonly role: string;
}

const user: User = { name: "Alice", role: "user" };
user.name = "Bob";  // ❌ TypeScript error: Cannot assign to 'name'
Enter fullscreen mode Exit fullscreen mode

5. Enable ESLint rules

// Add to your .eslintrc
{
  "rules": {
    "no-param-reassign": "error"  // Warns about parameter mutations
  }
}
Enter fullscreen mode Exit fullscreen mode

Understanding Object Equality

Here's something that confuses developers all the time:

const a = { x: 1 };
const b = { x: 1 };
const c = a;

console.log(a === b);  // false
console.log(a === c);  // true
Enter fullscreen mode Exit fullscreen mode

Why is a === b false when they look identical?

Both === and == compare pointers, not object contents. Even though a and b have the same properties and values, they're different objects on the heap. They occupy different memory locations.

Meanwhile, a === c is true because c = a copied the pointer. Both a and c point to the exact same heap object.

So how do you actually compare object contents?

For simple objects, you can use:

JSON.stringify(a) === JSON.stringify(b)  // true
Enter fullscreen mode Exit fullscreen mode

For complex objects with methods, dates, or circular references, use a library like Lodash:

import _ from 'lodash';

_.isEqual(a, b)  // true
Enter fullscreen mode Exit fullscreen mode

This understanding is crucial when working with React state, Redux reducers, or any code that depends on detecting changes in objects.

Common Misconceptions Cleared Up

"Primitives are always on the stack"

Not quite. Primitives in objects or closures live on the heap. JavaScript is smart about where to store things.

"Objects are slow because heap"

Modern engines optimize heavily. The performance difference is negligible for normal code. Don't micro-optimize based on this.

"const means the object is immutable"

Nope. const prevents reassignment of the variable, not mutation of the object it points to.

"Spread operator deep copies objects"

It's shallow only. Nested objects still share references.


Understanding stack vs heap isn't about memorizing rules. It's about building an accurate mental model of what JavaScript is actually doing when your code runs.

Once you have that model, the "bugs" aren't mysterious anymore. They're predictable. And predictable means fixable.


Top comments (0)