Skip to main content

How Garbage Collection Works in JavaScript

Every time you create an object, an array, a function, or any other non-primitive value in JavaScript, memory is allocated to store it. But what happens to that memory when you no longer need the data? Unlike languages such as C or C++, where developers must manually allocate and free memory, JavaScript handles this process automatically through a mechanism called garbage collection.

Understanding how garbage collection works is not just theoretical knowledge. It directly impacts the performance of your applications. Memory leaks, sluggish interfaces, and unexpected crashes in long-running applications are often caused by developers unknowingly preventing the garbage collector from doing its job.

This guide explains the memory lifecycle in JavaScript, the algorithms the engine uses to reclaim memory, the optimizations that make it efficient, and the real-world mistakes that cause memory leaks.

Memory Management in JavaScript: Automatic and Manual

Every program needs memory to operate. The memory lifecycle in any programming language follows three steps:

  1. Allocate memory when data is created
  2. Use that memory (read, write)
  3. Release memory when the data is no longer needed

In JavaScript, steps 1 and 2 are explicit. You create variables, objects, and arrays, and you use them. Step 3, however, is automatic. The JavaScript engine includes a garbage collector (GC) that periodically identifies memory that is no longer in use and frees it.

How Memory Is Allocated

Memory allocation happens implicitly whenever you write code that creates values:

// Allocating memory for a number
let age = 30;

// Allocating memory for a string
let name = "Alice";

// Allocating memory for an object and its properties
let user = {
name: "Alice",
scores: [95, 88, 72]
};

// Allocating memory for a function
function greet(person) {
return `Hello, ${person.name}`;
}

Every value in the code above occupies space in memory. Primitives are generally stored on the stack (small, fast, fixed-size), while objects, arrays, and functions are stored on the heap (larger, dynamically sized).

Why You Cannot Manually Free Memory

JavaScript provides no direct mechanism to free memory. There is no free(), no delete that deallocates objects (the delete operator only removes object properties), and no destructor pattern. The engine decides when to reclaim memory.

let data = { huge: new Array(1000000).fill("x") };

// You might think this frees the memory:
data = null;

// But the memory is not freed IMMEDIATELY.
// The garbage collector will reclaim it at some future point.

Setting a variable to null does not trigger garbage collection. It simply removes the reference, making the object eligible for collection. The engine decides when to actually reclaim it.

Why Automatic GC?

Manual memory management is a leading source of bugs in languages like C and C++. Memory leaks (forgetting to free), dangling pointers (using freed memory), and double-free errors are entire categories of bugs that JavaScript developers never face, thanks to automatic garbage collection.

The Concept of Reachability

The core principle that governs JavaScript's garbage collection is reachability. An object is kept alive in memory as long as it is reachable. The moment it becomes unreachable, it becomes a candidate for garbage collection.

What Makes a Value Reachable?

A value is considered reachable if it can be accessed or used in some way. There are two categories:

1. Root values (always reachable by definition):

  • The currently executing function's local variables and parameters
  • Variables and parameters of nested calls on the current call stack
  • Global variables
  • Internal engine references

These are called GC roots. They are the starting points.

2. Any value reachable from a root through a chain of references:

If a root references an object, that object is reachable. If that object references another object, that other object is also reachable. And so on, through any depth of nesting.

A Simple Example

let user = { name: "Alice" };

Here, the global variable user holds a reference to the object { name: "Alice" }. The global variable is a root, and the object is reachable through it.

Now, if we remove that reference:

let user = { name: "Alice" };
user = null;

The object { name: "Alice" } has no more references pointing to it. It is unreachable. The garbage collector will eventually reclaim its memory.

Multiple References

An object stays alive as long as at least one reference to it exists:

let user = { name: "Alice" };
let admin = user; // Two references to the same object

user = null; // Remove one reference

// The object is still alive: admin still references it
console.log(admin.name); // "Alice"

The object becomes unreachable only when all references to it are removed:

let user = { name: "Alice" };
let admin = user;

user = null;
admin = null;

// NOW the object is unreachable: eligible for garbage collection

Interlinked Objects

Consider a more complex scenario where objects reference each other:

function marry(man, woman) {
man.wife = woman;
woman.husband = man;

return {
father: man,
mother: woman
};
}

let family = marry(
{ name: "John" },
{ name: "Ann" }
);

This creates a network of references:

  • family references an object with father and mother properties
  • family.father (John) has a wife property referencing Ann
  • family.mother (Ann) has a husband property referencing John

Every object is reachable through multiple paths. Now, let's remove some references:

delete family.father;
delete family.mother.husband;

After these deletions, John's object has no incoming references. Even though John still has an outgoing reference to Ann (via wife), no one references John anymore. He is unreachable and will be garbage collected.

Ann remains reachable because family.mother still points to her.

An Unreachable Island

Sometimes, an entire group of objects can become unreachable at once:

let family = marry(
{ name: "John" },
{ name: "Ann" }
);

family = null;

Even though John and Ann still reference each other through wife and husband, the entire group is disconnected from the root (family was the only root reference). The garbage collector recognizes that the whole cluster is unreachable and collects all of it.

Incoming References Are What Matter

An object having outgoing references to other objects does not keep it alive. Only incoming references from reachable values matter. An island of mutually referencing objects will be collected if no root can reach any of them.

How the Mark-and-Sweep Algorithm Works

The fundamental algorithm used by JavaScript engines for garbage collection is called mark-and-sweep. It directly implements the reachability concept.

The Algorithm in Three Steps

Step 1: Identify the roots.

The garbage collector starts with all GC roots: global variables, current call stack variables, and other internal references.

Step 2: Mark (traverse and mark).

Starting from each root, the collector follows every reference, visiting every object that can be reached. Each visited object is marked as reachable.

This is essentially a graph traversal. The roots are starting nodes, references are edges, and objects are nodes.

Step 3: Sweep (collect unmarked).

After the marking phase completes, any object in the heap that was not marked is unreachable. The garbage collector sweeps through memory and frees all unmarked objects.

Visual Walkthrough

Consider this state of memory:

let a = { value: 1 };
let b = { value: 2, ref: a };
let c = { value: 3 };

// Current references:
// root "a" → { value: 1 }
// root "b" → { value: 2 } → (ref) → { value: 1 }
// root "c" → { value: 3 }

c = null; // Remove root reference to { value: 3 }

Mark phase: The collector starts from the roots. It visits a and marks { value: 1 }. It visits b, marks { value: 2 }, follows the ref property, and finds { value: 1 } (already marked). No more roots to process.

Sweep phase: { value: 3 } was never visited, never marked. It is swept (freed).

Why Mark-and-Sweep Handles Circular References

One major advantage of mark-and-sweep over simpler approaches like reference counting is its ability to handle circular references:

function createCycle() {
let objA = {};
let objB = {};
objA.ref = objB;
objB.ref = objA;
// Both have reference count of 1 after the function ends
}

createCycle();
// After the function returns, objA and objB reference each other,
// but neither is reachable from any root.
// Mark-and-sweep correctly identifies them as unreachable.

A naive reference-counting algorithm would see that both objects still have a reference count of 1 (each references the other) and would never free them. Mark-and-sweep does not have this problem because it traces from roots, and neither object is reachable from a root.

Internal Optimizations

The basic mark-and-sweep algorithm is conceptually simple, but running it on a large heap would pause the application for a noticeable amount of time. Modern JavaScript engines like V8 (Chrome, Node.js), SpiderMonkey (Firefox), and JavaScriptCore (Safari) implement several optimizations.

Generational Collection

Most objects in JavaScript are short-lived. A temporary variable inside a function, an intermediate calculation result, or a short-lived event object is created and discarded quickly. Long-lived objects (application state, cached data, DOM references) survive for much longer.

Generational garbage collection exploits this pattern by dividing the heap into two (or more) regions:

  • Young generation (nursery): Where new objects are allocated. This region is small and collected frequently. Most objects die here.
  • Old generation (tenured): Objects that survive multiple young-generation collections are promoted here. This region is larger and collected less frequently.

This optimization is highly effective because scanning the small young generation is fast, and most garbage is found there.

Incremental Collection

Instead of stopping the application to mark the entire heap at once (a stop-the-world pause), incremental collection breaks the marking phase into many small steps. The engine does a little bit of marking, lets the application run, does more marking, and so on.

This reduces the length of individual pauses, making the application feel more responsive, even though the total time spent on GC might be similar.

Idle-Time Collection

V8 and other engines try to perform garbage collection during idle periods, when the application is not busy. For example, if the browser is waiting for user input or the animation frame budget has not been exhausted, the engine can use that idle time to run collection work.

In Node.js, idle-time collection happens between event loop iterations when there is nothing in the task queue.

Concurrent and Parallel Collection

Modern engines can run parts of the garbage collection process on separate threads, concurrently with the main JavaScript thread. For example, V8 can perform marking and sweeping on helper threads while the main thread continues executing JavaScript.

This is transparent to the developer. You cannot control or configure it, but it explains why modern JavaScript applications rarely experience noticeable GC pauses.

Compaction

Over time, as objects are allocated and freed, the heap can become fragmented, with small gaps of free memory scattered between live objects. Compaction moves live objects together, eliminating gaps and making memory allocation faster. This is especially important for the old generation.

You Cannot Control GC

JavaScript provides no API to trigger, configure, or schedule garbage collection. The engine manages it entirely. Node.js has the --expose-gc flag for debugging purposes (enabling global.gc()), but this should never be used in production code.

Memory Leaks: Common Causes and How to Prevent Them

A memory leak occurs when memory that is no longer needed remains reachable, preventing the garbage collector from reclaiming it. Over time, leaked memory accumulates, causing the application to consume more and more RAM, eventually leading to slowdowns or crashes.

JavaScript's garbage collector is effective, but it can only collect what is truly unreachable. If your code accidentally keeps references to unused objects, the collector has no way to know you are done with them.

Leak 1: Accidental Global Variables

In non-strict mode, assigning to an undeclared variable creates a global variable. Global variables are roots and are never garbage collected (until the page is unloaded or the Node.js process exits).

// ❌ WRONG: Accidental global variable
function processData() {
results = computeExpensiveData(); // Missing "let" or "const"!
// "results" is now a global variable
}

processData();
// results stays in memory forever, even though processData is done

Fix:

// ✅ CORRECT: Always declare variables
"use strict"; // Prevents accidental globals

function processData() {
const results = computeExpensiveData();
// results is local: garbage collected after function returns
return results;
}
tip

Always use "use strict" or ES modules (which are strict by default). Strict mode throws a ReferenceError when you assign to an undeclared variable, catching the bug immediately.

Leak 2: Forgotten Timers

setInterval keeps running until explicitly cleared. If the callback references large data structures, those structures cannot be garbage collected as long as the interval is active.

// ❌ WRONG: Interval never cleared
function startPolling() {
const hugeData = new Array(1000000).fill("important data");

setInterval(() => {
// This closure keeps "hugeData" alive forever
console.log(hugeData.length);
}, 5000);
}

startPolling();
// hugeData can NEVER be collected: the interval callback references it

Fix:

// ✅ CORRECT: Store the interval ID and clear it when done
function startPolling() {
const hugeData = new Array(1000000).fill("important data");

const intervalId = setInterval(() => {
console.log(hugeData.length);
}, 5000);

// Clear the interval when no longer needed
// For example, after 30 seconds:
setTimeout(() => {
clearInterval(intervalId);
console.log("Polling stopped, hugeData can now be collected");
}, 30000);
}

The same applies to setTimeout if you store the callback in a long-lived reference.

Leak 3: Forgotten Event Listeners

Adding event listeners to DOM elements creates references between your JavaScript code and the DOM. If you remove the DOM element but forget to remove the listener (or if the listener closure captures large variables), memory can leak.

// ❌ WRONG: Listener keeps large data alive
function setupPage() {
const hugeCache = new Array(1000000).fill("cached data");

const button = document.getElementById("myButton");
button.addEventListener("click", () => {
console.log(hugeCache[0]); // Closure captures hugeCache
});
}

setupPage();
// Even if #myButton is later removed from the DOM,
// the listener (and hugeCache) may persist if not cleaned up properly

Fix:

// ✅ CORRECT: Remove listeners when the element is removed
function setupPage() {
const hugeCache = new Array(1000000).fill("cached data");
const button = document.getElementById("myButton");

function handleClick() {
console.log(hugeCache[0]);
}

button.addEventListener("click", handleClick);

// When removing the element:
function cleanup() {
button.removeEventListener("click", handleClick);
button.remove();
// Now handleClick and hugeCache can be collected
}

return cleanup;
}

const cleanupPage = setupPage();
// Later, when the section is no longer needed:
cleanupPage();

Alternatively, use the once option for one-time listeners, or use AbortController to manage groups of listeners:

// ✅ Using AbortController to remove multiple listeners at once
const controller = new AbortController();

button.addEventListener("click", handleClick, { signal: controller.signal });
button.addEventListener("mouseover", handleHover, { signal: controller.signal });

// Remove all listeners managed by this controller:
controller.abort();

Leak 4: Closures That Capture More Than Needed

Closures capture the variables they reference from their outer scope. Sometimes, a closure unintentionally captures a large variable even though it only needs a small piece of data.

// ❌ WRONG: Closure captures the entire large object
function processUser(user) {
const allUserData = fetchLargeDataSet(user.id);
// allUserData is a massive object

return function getUsername() {
// Only needs user.name, but closure captures allUserData too
// because allUserData is in the same scope
return user.name;
};
}

const getName = processUser({ id: 1, name: "Alice" });
// allUserData cannot be collected as long as getName exists

Fix:

// ✅ CORRECT: Extract only what you need before creating the closure
function processUser(user) {
const allUserData = fetchLargeDataSet(user.id);
const userName = user.name; // Extract the small piece

// Process allUserData here...

return function getUsername() {
return userName; // Only captures the small string
};
// allUserData can now be collected after processUser returns
}
Modern Engines Are Smart

V8 and other modern engines perform scope analysis and can sometimes optimize away variables that a closure does not actually reference. However, this optimization is not guaranteed, especially in complex scenarios with eval or debugger statements. It is always better to be explicit.

Leak 5: Detached DOM Elements

When you remove a DOM element from the document but still hold a JavaScript reference to it, the element (and its subtree) remains in memory.

// ❌ WRONG: Detached DOM node leaks memory
let cachedElement = null;

function showNotification(message) {
const notification = document.createElement("div");
notification.textContent = message;
document.body.appendChild(notification);

cachedElement = notification; // Stored reference

setTimeout(() => {
notification.remove(); // Removed from DOM
// But cachedElement STILL references it: it's detached, not collected
}, 3000);
}

Fix:

// ✅ CORRECT: Null out references after removal
function showNotification(message) {
const notification = document.createElement("div");
notification.textContent = message;
document.body.appendChild(notification);

setTimeout(() => {
notification.remove();
// No external references: node and its subtree can be collected
}, 3000);
}

If you must cache DOM references (for performance), ensure you null them out when the elements are removed.

Leak 6: Growing Data Structures Without Bounds

This is a logical leak rather than a reference leak. If you continuously add data to an array, Map, Set, or object without ever removing old entries, memory grows without bound.

// ❌ WRONG: Unbounded cache
const cache = new Map();

function processRequest(id, data) {
cache.set(id, data); // Cache grows forever
return transform(data);
}

Fix:

// ✅ CORRECT: Bounded cache with eviction
const cache = new Map();
const MAX_CACHE_SIZE = 1000;

function processRequest(id, data) {
if (cache.size >= MAX_CACHE_SIZE) {
// Remove the oldest entry (first key in Map preserves insertion order)
const oldestKey = cache.keys().next().value;
cache.delete(oldestKey);
}
cache.set(id, data);
return transform(data);
}

For caching scenarios, consider using WeakMap when the keys are objects. A WeakMap does not prevent its keys from being garbage collected:

// ✅ WeakMap automatically cleans up when keys are collected
const cache = new WeakMap();

function processUser(user) {
if (cache.has(user)) {
return cache.get(user);
}

const result = expensiveComputation(user);
cache.set(user, result);
return result;
}
// When the "user" object is no longer referenced elsewhere,
// its entry in the WeakMap is automatically removed

Using Chrome DevTools Memory Panel

When you suspect a memory leak or want to understand your application's memory behavior, Chrome DevTools provides powerful tools for investigation.

Opening the Memory Panel

  1. Open Chrome DevTools (F12 or Ctrl+Shift+I / Cmd+Option+I)
  2. Navigate to the Memory tab
  3. You will see three profiling options

Heap Snapshot

A heap snapshot captures the state of all objects in memory at a single point in time.

How to use it to find leaks:

  1. Perform the action you suspect is leaking (for example, open and close a modal dialog)
  2. Click "Take snapshot" before the action
  3. Perform the action several times
  4. Click "Take snapshot" again
  5. Select the second snapshot and change the view to "Comparison"
  6. Look for objects that were created but not destroyed between snapshots

The comparison view shows you:

  • # New: Objects created since the previous snapshot
  • # Deleted: Objects removed since the previous snapshot
  • # Delta: The difference (positive delta means objects are accumulating)

A growing delta for a specific constructor (like HTMLDivElement, Object, or your custom class names) indicates a potential leak.

Allocation Timeline

The Allocation instrumentation on timeline records memory allocations over time while you interact with the application.

  1. Select "Allocation instrumentation on timeline"
  2. Click Start
  3. Perform the actions you want to analyze
  4. Click Stop

The timeline shows blue bars for allocations. Bars that remain blue (not turned gray) represent objects that are still alive at the end of the recording. You can click on these bars to see what objects were allocated and inspect their retaining paths.

Allocation Sampling

Allocation sampling is a lightweight alternative to the allocation timeline. It uses statistical sampling to approximate where memory is being allocated, with minimal performance overhead. This is useful for long-running profiling sessions.

The Retainers Panel

When you find a suspicious object in any of the memory profiling views, clicking on it shows its retainers, the chain of references that keeps the object alive. This is the most important information for diagnosing leaks.

For example, you might see:

YourLeakedObject
← property "cache" of Map
← variable "cache" in processRequest (script.js:15)
← (global scope)

This tells you that YourLeakedObject is alive because a global cache Map references it, and you can go directly to script.js line 15 to fix it.

The Performance Monitor

For a quick, real-time overview, open the Performance Monitor:

  1. Press Ctrl+Shift+P (or Cmd+Shift+P on Mac) to open the Command Menu
  2. Type "Performance Monitor" and select "Show Performance Monitor"

This displays live metrics including:

  • JS heap size: Current JavaScript memory usage
  • DOM Nodes: Number of DOM nodes in the document
  • JS event listeners: Number of active event listeners

If any of these numbers keep increasing as you use the application without returning to baseline, you likely have a leak.

Practical Debugging Workflow
  1. Use the Performance Monitor to confirm memory is growing
  2. Take two heap snapshots (before and after the suspected leaky action) and compare
  3. Identify the objects with a positive delta
  4. Inspect their retainers to find what is keeping them alive
  5. Fix the retaining reference in your code
  6. Repeat to verify the fix

Summary

  • JavaScript manages memory automatically through garbage collection. You cannot manually free memory.
  • The garbage collector uses the concept of reachability: any value accessible from a root (global variable, current call stack) through any chain of references is kept alive. Everything else is collected.
  • The core algorithm is mark-and-sweep: start from roots, mark everything reachable, sweep everything else.
  • Modern engines optimize with generational collection (young/old heaps), incremental marking (small pauses), idle-time collection, and concurrent/parallel processing.
  • Memory leaks happen when your code accidentally keeps references to objects that are no longer needed. The five most common causes are accidental globals, forgotten timers/intervals, forgotten event listeners, closures capturing large variables, and detached DOM nodes.
  • Use Chrome DevTools Memory panel (heap snapshots, allocation timeline, retainers view) to diagnose and fix leaks.
  • Use WeakMap and WeakSet for caches and metadata that should not prevent garbage collection.
  • Always clean up timers, event listeners, and DOM references when they are no longer needed.

Understanding garbage collection empowers you to write JavaScript that stays fast and memory-efficient, especially in long-running applications like single-page apps, servers, and real-time dashboards.