JavaScript Memory Model: Heap, Stack, and Garbage Collection

JavaScript’s memory model explains where values live, how function calls are tracked, and how unused data gets removed automatically. If you understand the stack, the heap, and garbage collection, it becomes much easier to reason about performance, references, leaks, and “why did this value change?” bugs.

Quick answer: JavaScript keeps short-lived execution data on the call stack and most objects on the heap. Garbage collection automatically frees heap memory that is no longer reachable, so you usually do not manage memory manually.

Difficulty: Beginner

You’ll understand this better if you know: basic variables, functions, objects, and how a function call works in JavaScript.

1. What Is the JavaScript Memory Model?

The memory model is the way a JavaScript engine organizes data while your code runs. It is not a single feature you write in code; it is the engine’s internal system for storing values, tracking function calls, and cleaning up unused memory.

This model matters because many common JavaScript behaviors, such as object mutation, closure retention, and memory leaks, make sense only when you know how memory is organized.

2. Why the Memory Model Matters

Most developers do not need to allocate or free memory manually in JavaScript, but they do need to understand what the engine is doing behind the scenes.

In browser apps, memory issues can affect scrolling, responsiveness, and tab stability. In Node.js, they can affect server throughput and process crashes.

3. Basic Syntax or Core Idea

JavaScript does not expose the stack and heap directly, but the way you write variables and functions maps to those internal structures. A minimal example shows the difference between a primitive value and an object reference.

Primitive values and object references

let count = 3;
let user = { name: "Ava" };

let anotherCount = count;
let anotherUser = user;

count holds a primitive number, so assigning it to anotherCount copies the value. user is an object, so assigning it to anotherUser copies the reference to the same object.

If one variable changes the object, the other variable sees the same update because both point to the same heap allocation.

4. Step-by-Step Examples

Example 1: Copying a primitive

Primitives such as numbers, strings, booleans, null, undefined, symbols, and bigints are copied by value. This means the new variable gets its own independent value.

let score = 10;
let copiedScore = score;

copiedScore = 20;

console.log(score);        // 10
console.log(copiedScore); // 20

The original score stays unchanged because the assignment copied the value, not a shared container.

Example 2: Sharing an object reference

Objects, arrays, and functions are reference types in practice. The variable stores a reference, while the actual data lives on the heap.

let profile = { name: "Mina", active: true };
let sameProfile = profile;

sameProfile.active = false;

console.log(profile.active);     // false
console.log(sameProfile.active); // false

Both variables point to the same object, so a mutation through either name affects the same heap data.

Example 3: Function calls on the stack

Each function call creates a new stack frame. That frame stores the function’s local variables, parameters, and where execution should continue after the function returns.

function addTax(price) {
  let taxRate = 0.2;
  return price + (price * taxRate);
}

let total = addTax(100);

When addTax finishes, its stack frame is removed. The returned number remains available because it is stored as the result of the expression that called the function.

Example 4: Closures keep data alive

A closure can keep variables alive even after the outer function finishes. This is one of the most important memory-model concepts in JavaScript.

function makeCounter() {
  let count = 0;

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

const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2

The inner function still needs count, so the engine preserves that data in memory even after makeCounter returns.

5. Practical Use Cases

These scenarios are common in web apps, Node.js services, and code that uses long-lived closures.

6. Common Mistakes

Mistake 1: Assuming objects are copied automatically

Many beginners expect = to make a deep copy of an object. In reality, it copies the reference, so both variables point at the same object.

Problem: This code mutates the original object because both variables reference the same heap value.

let settings = { theme: "dark" };
let copy = settings;

copy.theme = "light";
console.log(settings.theme); // light

Fix: Create a new object when you need independent data.

let settings = { theme: "dark" };
let copy = { ...settings };

copy.theme = "light";
console.log(settings.theme); // dark

The corrected version works because the spread operator creates a new top-level object.

Mistake 2: Believing closures only live for one function call

A closure can retain references to variables long after the outer function ends. This is useful, but it can also keep large data structures in memory unexpectedly.

Problem: The returned function keeps the large array reachable, so the memory cannot be reclaimed while the callback exists.

function createProcessor() {
  let cache = new Array(100000).fill("data");

  return function process() {
    return cache.length;
  };
}

const process = createProcessor();

Fix: Keep large data out of long-lived closures when you do not need it later.

function createProcessor(size) {
  return function process() {
    let cache = new Array(size).fill("data");
    return cache.length;
  };
}

const process = createProcessor(100000);

The corrected version reduces retention because the large array is created only when the inner function runs.

Mistake 3: Writing recursive code without a base case

Recursion uses the call stack for each nested call. Without a stopping condition, the stack grows until the engine throws a range error.

Problem: This function calls itself forever, which can lead to RangeError: Maximum call stack size exceeded.

function countDown(n) {
  return countDown(n - 1);
}

countDown(3);

Fix: Add a base case so the function stops calling itself.

function countDown(n) {
  if (n <= 0) {
    return "done";
  }

  return countDown(n - 1);
}

countDown(3);

The corrected version works because the recursion ends before the stack grows without limit.

7. Best Practices

Practice 1: Prefer immutable updates for shared state

When multiple parts of a program can observe the same object, direct mutation makes behavior harder to predict. Creating a new object keeps state changes easier to trace.

const state = { count: 1 };
const nextState = { ...state, count: state.count + 1 };

Practice 2: Release references when you no longer need them

Garbage collection can only reclaim memory that is unreachable. If you keep references around in arrays, caches, globals, or closures, the memory stays live.

let items = ["a", "b", "c"];
items = null; // allow the array to become unreachable

Practice 3: Be careful with long-lived event handlers and timers

Callbacks can keep objects alive longer than expected because the engine must preserve everything the callback can still reach.

const config = { theme: "dark" };

const timerId = setInterval(() => {
  console.log(config.theme);
}, 1000);

clearInterval(timerId);

Clearing the timer lets the callback become unreachable, which helps the engine recover the associated memory.

8. Limitations and Edge Cases

Note: JavaScript memory behavior is observable, but it is not usually deterministic. Two runs of the same program may reclaim memory at different moments.

9. Practical Mini Project

Here is a small example that shows how references, copies, and garbage collection-related cleanup can appear in a simple task tracker.

function createTaskManager() {
  let tasks = [];

  return {
    addTask(title) {
      tasks.push({ title, done: false });
    },
    completeTask(index) {
      if (tasks[index]) {
        tasks[index].done = true;
      }
    },
    removeCompleted() {
      tasks = tasks.filter(task => !task.done);
    },
    listTasks() {
      return tasks.map(task => ({ ...task }));
    }
  };
}

const manager = createTaskManager();
manager.addTask("Learn memory model");
manager.addTask("Review closures");
manager.completeTask(0);
manager.removeCompleted();

console.log(manager.listTasks());

This project shows three core ideas together: local stack state inside each function call, heap-allocated task objects stored in an array, and a closure that keeps tasks alive while the manager exists. When tasks are removed from the array and no other references exist, they become eligible for garbage collection.

10. Key Points

11. Practice Exercise

Try this to check your understanding of JavaScript memory behavior.

Expected output: In the first version, both variables reflect the nested mutation. In the second version, only the copied object changes.

Hint: A shallow copy is enough to separate the top-level object, but nested objects and arrays need their own copies too.

const original = {
  name: "Project",
  tags: ["draft"]
};

const copy = {
  ...original,
  tags: [...original.tags]
};

copy.tags.push("review");

console.log(original.tags); // ["draft"]
console.log(copy.tags);     // ["draft", "review"]

12. Final Summary

JavaScript’s memory model is easiest to understand as three related ideas: the call stack tracks execution, the heap stores objects and other managed data, and garbage collection removes memory that can no longer be reached. Together, these explain why primitives behave independently, why objects are shared by reference, and why closures can preserve state after a function returns.

Once you understand this model, debugging becomes much easier. You can predict when values will change, when memory might stay alive longer than expected, and why recursion or long-lived references can cause problems in real applications.

If you want to go deeper next, study closures, object references, and browser memory profiling tools. Those topics build directly on the memory model and make performance issues much easier to investigate.