Garbage Collection

Learn how JavaScript automatically manages memory with garbage collection, how it works behind the scenes, and how to avoid common memory leaks in your code.

Loading...
Garbage Collection

In JavaScript, you don’t need to manually clean up memory. The JavaScript engine (like V8 in Chrome) does that for you. This process is called Garbage Collection.

But that doesn’t mean you can forget about memory completely.

Even though Garbage Collection is automatic, understanding it helps you write faster apps and avoid memory leaks that can crash your application.


What Is Garbage Collection?

Garbage Collection (GC) is how JavaScript automatically clears memory that’s no longer needed.

You can think of GC like a cleaning robot, if a variable or object is no longer being used in your code, then the robot comes in and clears it out to free up space.

This helps your program run faster and use less memory.

But sometimes, the robot doesn’t know something should be thrown away and that can cause memory problems. That’s why it’s helpful to understand how it works.


Memory Lifecycle in JavaScript

Every variable in JavaScript goes through this lifecycle:

  • Allocation: Memory is allocated when you create variables, objects, or functions.

    let user = { name: "Alice", age: 30 };
  • Usage: You read and write to the allocated memory.

    console.log(user.name);
    user.age = 31;
  • Release: Memory is freed when it's no longer needed (garbage collection).


How JavaScript Determines What to Collect

JavaScript uses reachability to determine what can be garbage collected.

JavaScript keeps memory only for values that are reachable, meaning there’s still a way to access them.

A value is considered reachable if:

  • It can be accessed from the current execution context
  • It's referenced by a reachable object
  • It's in the global scope
  • It's referenced by a closure

Everything else will be cleaned up.

Example:

let car = {
  brand: "Toyota",
  model: "Fortuner",
};
 
car = null; // The original object is no longer reachable

Here,

  • When car was assigned an object, memory was allocated.
  • When you set car = null, that object is no longer reachable.
  • At some point, JS will remove it from memory automatically.

Garbage Collection Algorithms

Let’s look at how JavaScript engines decide what to delete.

1. Reference Counting (Old Method)

In the early days, JavaScript engines used something called reference counting.

The idea was simple, every object keeps a count of how many things are pointing to it (referencing it).

When the count becomes 0, the object is no longer needed and can be deleted.

let obj1 = { name: "Object 1" }; // Reference count: 1
let obj2 = obj1; // Reference count: 2
obj1 = null; // Reference count: 1
obj2 = null; // Reference count: 0 → eligible for GC

Problem with Reference Counting: Circular references

Reference counting struggles with objects that refer to each other (a cycle), even if nothing else is using them.

function createCircularReference() {
  let obj1 = {};
  let obj2 = {};
 
  obj1.ref = obj2; // obj1 references obj2
  obj2.ref = obj1; // obj2 references obj1
 
  return obj1;
}
 
let circular = createCircularReference();
circular = null; // obj1 and obj2 still reference each other
// With pure reference counting, this would be a memory leak

Memory Leak: When memory is no longer needed but never gets cleaned up. Over time, this can slow down or crash your app.

2. Mark-and-Sweep (Modern Approach)

Modern JavaScript engines use the mark-and-sweep algorithm:

  • Mark Phase: Starting from roots (global variables, current function variables), mark all reachable objects
  • Sweep Phase: Free memory of all unmarked objects

Here’s how it works:

  • Mark Phase:
    • Start from the "roots" — things always in use like:
      • Global variables
      • Local variables in active functions
    • Follow all references and mark everything reachable.
  • Sweep Phase:
    • Anything not marked is considered unused and is deleted.
// Root objects (always marked)
let globalVar = { data: "important" };
 
function processData() {
  // Local variables (marked when function is active)
  let localVar = { temp: "data" };
 
  // This object is marked because it's reachable from localVar
  localVar.nested = { value: 42 };
 
  return localVar.nested;
}
 
let result = processData();

Here,

  • globalVar: still marked (global scope)
  • result: still marked (references the nested object)
  • localVar: unmarked (out of scope) → eligible for GC
  • localVar.nested: still marked because result points to it

The mark-and-sweep algorithm handles circular references safely, so modern engines don’t leak memory in those cases.


Common GC Mistake: Memory Leaks

A memory leak happens when your program keeps using memory that it no longer needs and never releases it.

Even though JavaScript has automatic garbage collection, memory leaks can still happen if:

  • You keep referencing objects that are no longer useful.
  • The garbage collector can’t clean them up, because they’re still considered in use.

You can think of it like this, you have a shelf (memory), and every time you use something (like an object), you put it there. When you're done, if you forget to remove it from the shelf, it just sits there forever, even if you never use it again. Over time, the shelf gets full.


Common Memory Leaks and How to Avoid Them

1. Accidental Global Variables

// BAD: Creates a global variable
function leakyFunction() {
  leakyVar = "I'm global!"; // Missing 'let', 'const', or 'var'
}
 
// GOOD: Use strict mode and proper declarations
("use strict");
function cleanFunction() {
  let properVar = "I'm local!";
}

2. Forgotten Event Listeners

// BAD: Event listener keeps reference to DOM element
function addListener() {
  let element = document.getElementById("button");
  let data = new Array(1000000).fill("data"); // Large array
 
  element.addEventListener("click", function () {
    console.log(data[0]); // Closure keeps 'data' alive
  });
}
 
// GOOD: Remove event listeners when done
function addListenerProperly() {
  let element = document.getElementById("button");
  let data = new Array(1000000).fill("data");
 
  function clickHandler() {
    console.log(data[0]);
  }
 
  element.addEventListener("click", clickHandler);
 
  // Clean up when element is removed
  return function cleanup() {
    element.removeEventListener("click", clickHandler);
  };
}

3. Closures Holding References

// BAD: Closure holds reference to large object
function createHandler() {
  let largeData = new Array(1000000).fill("data");
  let smallData = "small";
 
  return function () {
    console.log(smallData); // Only needs smallData, but closure keeps largeData alive
  };
}
 
// GOOD: Minimize closure scope
function createHandlerProperly() {
  let largeData = new Array(1000000).fill("data");
  let smallData = "small";
 
  // Process large data here if needed
  processLargeData(largeData);
 
  // Return closure that only captures what it needs
  return function () {
    console.log(smallData); // largeData is not in closure scope
  };
}

4. Timers and Intervals

// BAD: Timer keeps references alive
function startTimer() {
  let data = new Array(1000000).fill("data");
 
  setInterval(function () {
    console.log(data.length); // Keeps 'data' alive forever
  }, 1000);
}
 
// GOOD: Clear timers when done
function startTimerProperly() {
  let data = new Array(1000000).fill("data");
 
  let timerId = setInterval(function () {
    console.log(data.length);
  }, 1000);
 
  // Clear timer when component is destroyed
  return function cleanup() {
    clearInterval(timerId);
  };
}

5. Detached DOM Elements

// BAD: Keeping references to removed DOM elements
let elements = [];
 
function addElement() {
  let div = document.createElement("div");
  document.body.appendChild(div);
  elements.push(div); // Keeps reference
}
 
function removeElement() {
  let div = elements.pop();
  div.remove(); // Removed from DOM but still referenced in array
}
 
// GOOD: Clean up references
function removeElementProperly() {
  let div = elements.pop();
  div.remove();
  // div reference is automatically cleaned up when function ends
}

WeakMap and WeakSet: Built-in Memory Leak Prevention

JavaScript provides WeakMap and WeakSet data structures that are specifically designed to avoid memory leaks in certain scenarios.

WeakMap

Unlike regular Map, WeakMap uses weak references to its keys. This means if the key object is no longer referenced elsewhere, it can be garbage collected along with its value.

// BAD: Regular Map prevents garbage collection
let regularMap = new Map();
let obj = { name: "Alice" };
 
regularMap.set(obj, "some metadata");
obj = null; // Object can't be garbage collected because Map still references it
 
// GOOD: WeakMap allows garbage collection
let weakMap = new WeakMap();
let obj2 = { name: "Bob" };
 
weakMap.set(obj2, "some metadata");
obj2 = null; // Object can be garbage collected, WeakMap entry automatically removed

Common use cases for WeakMap:

  • Storing metadata about objects without affecting their lifecycle
  • Private data in classes
  • Caching results tied to specific objects
// Example: Private data using WeakMap
const privateData = new WeakMap();
 
class User {
  constructor(name, email) {
    privateData.set(this, { name, email });
  }
 
  getName() {
    return privateData.get(this).name;
  }
}
 
let user = new User("Alice", "alice@example.com");
// When user is garbage collected, privateData entry is automatically removed

WeakSet

WeakSet is similar to WeakMap but stores only objects (not key-value pairs) and uses weak references.

let weakSet = new WeakSet();
let obj = { id: 1 };
 
weakSet.add(obj);
obj = null; // Object can be garbage collected, automatically removed from WeakSet

When to use WeakMap/WeakSet:

  • When you need to associate data with objects without preventing their garbage collection
  • For temporary object tracking
  • When implementing observer patterns or event systems

Learn more about WeakMap and WeakSet!


How to Detect Memory Leaks

Understanding memory leaks is one thing, but detecting them in your applications is equally important. Here are practical ways to identify and debug memory issues:

Browser Developer Tools

1. Chrome DevTools Memory Tab

Chrome provides excellent tools for memory analysis:

  • Memory Usage Timeline: Shows memory usage over time
  • Heap Snapshots: Capture memory state at specific moments
  • Allocation Timeline: Track memory allocations in real-time

How to use it:

  1. Open DevTools → Memory tab
  2. Take a heap snapshot before your action
  3. Perform the action that might cause a leak
  4. Take another heap snapshot
  5. Compare snapshots to see what wasn't cleaned up

2. Performance Tab

The Performance tab shows memory usage alongside CPU usage:

  1. Open DevTools → Performance tab
  2. Check "Memory" checkbox
  3. Record your application usage
  4. Look for steadily increasing memory usage (indicates potential leaks)

Programmatic Memory Monitoring

1. Using performance.memory (Chrome only)

function checkMemoryUsage() {
  if (performance.memory) {
    console.log({
      used: Math.round(performance.memory.usedJSHeapSize / 1048576) + " MB",
      total: Math.round(performance.memory.totalJSHeapSize / 1048576) + " MB",
      limit: Math.round(performance.memory.jsHeapSizeLimit / 1048576) + " MB",
    });
  }
}
 
// Monitor memory usage periodically
setInterval(checkMemoryUsage, 5000);

2. Creating a Simple Memory Monitor

class MemoryMonitor {
  constructor() {
    this.measurements = [];
    this.isMonitoring = false;
  }
 
  start() {
    this.isMonitoring = true;
    this.monitor();
  }
 
  stop() {
    this.isMonitoring = false;
  }
 
  monitor() {
    if (!this.isMonitoring) return;
 
    if (performance.memory) {
      const usage = performance.memory.usedJSHeapSize / 1048576; // MB
      this.measurements.push({
        timestamp: Date.now(),
        memory: usage,
      });
 
      // Alert if memory usage increases consistently
      if (this.measurements.length > 10) {
        const recent = this.measurements.slice(-10);
        const trend = this.calculateTrend(recent);
 
        if (trend > 1) {
          // Memory increasing by >1MB per measurement
          console.warn("Potential memory leak detected!");
        }
      }
    }
 
    setTimeout(() => this.monitor(), 1000);
  }
 
  calculateTrend(measurements) {
    if (measurements.length < 2) return 0;
 
    const first = measurements[0].memory;
    const last = measurements[measurements.length - 1].memory;
    return (last - first) / measurements.length;
  }
}
 
// Usage
const monitor = new MemoryMonitor();
monitor.start();

Support my work!