JavaScript Essentials

Memory Management & Garbage Collection

50 min Lesson 59 of 60

Understanding Memory in JavaScript

Memory management is one of the most critical yet often overlooked aspects of JavaScript development. Unlike languages such as C or C++ where developers manually allocate and free memory, JavaScript uses automatic memory management through a process called garbage collection. However, understanding how memory works behind the scenes is essential for writing performant applications that do not leak memory over time. In this lesson, you will learn the complete memory lifecycle, how the garbage collector works, common memory leak patterns, and how to use modern tools and APIs to diagnose and prevent memory issues in your applications.

Every time you create a variable, object, function, or any data structure in JavaScript, the engine must find space in memory to store it. When that data is no longer needed, the engine must reclaim that space so it can be reused. This process sounds simple in theory, but the details of how it works have profound implications for application performance, stability, and user experience.

The Memory Lifecycle

Regardless of the programming language, memory management always follows three fundamental steps. Understanding these steps is the foundation for everything else in this lesson.

Step 1: Allocation. When you declare a variable or create an object, the JavaScript engine allocates memory to store the value. For primitive values like numbers and booleans, the engine allocates a fixed amount of memory. For objects, arrays, and functions, the engine allocates enough memory to hold all their properties and data, plus internal metadata the engine needs to manage them.

Step 2: Usage. This is when your program reads from and writes to the allocated memory. Every time you access a variable, call a function, or modify an object property, you are using allocated memory. This step is explicit in your code -- it is what your application logic does.

Step 3: Release. When the allocated memory is no longer needed, it should be released so it can be reused. In JavaScript, this step is handled automatically by the garbage collector. The challenge is that determining when memory is truly "no longer needed" is an undecidable problem in the general case, so garbage collectors use approximations that work well in practice but are not perfect.

Example: Memory Lifecycle in Action

// Step 1: Allocation
// The engine allocates memory for a number
let age = 30;

// The engine allocates memory for a string
let name = 'Edrees';

// The engine allocates memory for an object and its properties
let user = {
    firstName: 'Edrees',
    lastName: 'Salih',
    skills: ['JavaScript', 'PHP', 'Laravel']
};

// Step 2: Usage
// Reading from allocated memory
console.log(user.firstName);

// Writing to allocated memory
user.age = age;

// Creating new references to existing memory
let skills = user.skills;

// Step 3: Release
// When variables go out of scope or are set to null,
// the garbage collector can reclaim their memory
user = null;
// Note: the skills array is NOT collected yet because
// the variable 'skills' still references it

Stack vs Heap Memory

JavaScript engines use two main memory regions to store data: the stack and the heap. Understanding the difference between them helps you reason about performance characteristics and memory behavior in your applications.

The Stack is a structured, fast-access memory region that operates in a Last-In-First-Out (LIFO) manner. It stores primitive values (numbers, strings, booleans, null, undefined, symbols, and BigInts) and references to objects. The stack also stores function call frames -- each time a function is called, a new frame is pushed onto the stack with the function's local variables and parameters. When the function returns, that frame is popped off the stack, and the memory is immediately reclaimed. Stack memory allocation and deallocation is extremely fast because it only involves moving a pointer.

The Heap is a large, unstructured memory region used for storing objects, arrays, functions, and other complex data types. Unlike the stack, heap memory is not automatically reclaimed when a function returns. Instead, it is managed by the garbage collector, which periodically scans the heap to find objects that are no longer reachable from the program's root references. Heap allocation is slower than stack allocation because the engine must search for a suitable block of free memory and manage fragmentation.

Example: Stack vs Heap Storage

function createUser(firstName, lastName) {
    // 'firstName' and 'lastName' are stored on the stack
    // as part of this function's call frame

    // The primitive value 25 is stored on the stack
    let age = 25;

    // The object is stored on the heap
    // The variable 'user' on the stack holds a REFERENCE
    // (memory address) pointing to the heap location
    let user = {
        firstName: firstName,
        lastName: lastName,
        age: age
    };

    // The array is also stored on the heap
    let hobbies = ['coding', 'reading', 'hiking'];

    // When this function returns, the stack frame is popped
    // but the returned object remains on the heap
    return user;
}

// 'result' holds a reference to the heap object
let result = createUser('Edrees', 'Salih');

// 'hobbies' array from inside the function is now unreachable
// and eligible for garbage collection
Note: Modern JavaScript engines like V8 (Chrome, Node.js), SpiderMonkey (Firefox), and JavaScriptCore (Safari) may optimize where values are stored. For example, the engine might store small objects on the stack if it can prove through escape analysis that they do not outlive the function call. These are implementation details that vary between engines and versions.

Garbage Collection: The Concept

Garbage collection (GC) is the automatic process of identifying memory that is no longer being used by the program and reclaiming it. The core concept is reachability: an object is considered "alive" and must be kept in memory if it can be reached, directly or indirectly, from a set of root references. Root references include global variables, the currently executing function's local variables and parameters, and other internal engine references.

If an object cannot be reached from any root through any chain of references, it is considered garbage and can be safely collected. The garbage collector runs periodically and automatically -- you generally cannot control when it runs, though you can influence its behavior through how you structure your code.

Reference Counting Algorithm

Reference counting is one of the simplest garbage collection strategies. The idea is straightforward: keep a count of how many references point to each object. When the count drops to zero, the object can be freed. While modern engines do not use pure reference counting as their primary GC strategy, understanding it helps you grasp why certain patterns create memory leaks.

Example: How Reference Counting Works

// Reference count for the object starts at 1
let obj = { name: 'Edrees' };      // refcount: 1

// Create another reference to the same object
let anotherRef = obj;               // refcount: 2

// Remove the first reference
obj = null;                          // refcount: 1

// Remove the second reference
anotherRef = null;                   // refcount: 0
// Object can now be collected


// THE FATAL FLAW: Circular References
function createCircularLeak() {
    let objA = {};
    let objB = {};

    // Create circular reference
    objA.partner = objB;   // objB refcount: 2
    objB.partner = objA;   // objA refcount: 2

    // Even after nullifying both variables:
    objA = null;           // objA refcount: 1 (objB still references it)
    objB = null;           // objB refcount: 1 (objA still references it)

    // Neither reaches 0, so neither is collected!
    // This is a memory leak with pure reference counting
}
Warning: Circular references were a serious problem in older browsers, particularly Internet Explorer 6 and 7, which used reference counting for DOM objects. Modern browsers use mark-and-sweep, which handles circular references correctly. However, understanding this limitation helps you appreciate why mark-and-sweep became the standard approach.

Mark-and-Sweep Algorithm

Mark-and-sweep is the foundation of modern JavaScript garbage collection. The algorithm works in two phases. In the mark phase, the collector starts from root references and traverses all reachable objects, marking each one as "alive." In the sweep phase, the collector scans through all objects in the heap and frees any that were not marked during the mark phase. After sweeping, all marks are cleared in preparation for the next cycle.

The key advantage of mark-and-sweep over reference counting is that it correctly handles circular references. If two objects reference each other but neither is reachable from any root, they will not be marked during the mark phase and will be swept away. This solves the fundamental problem of reference counting.

Example: Mark-and-Sweep Handles Circular References

function demonstrateMarkAndSweep() {
    let objA = { name: 'Object A' };
    let objB = { name: 'Object B' };

    // Create circular reference
    objA.ref = objB;
    objB.ref = objA;

    // At this point, both objects are reachable from
    // the function's local variables (roots)

    objA = null;
    objB = null;

    // Now neither object is reachable from any root
    // Mark-and-sweep will:
    // 1. Start from roots (global object, call stack)
    // 2. Cannot reach either object through any chain
    // 3. Both remain unmarked
    // 4. Both are swept and freed
    // No leak!
}

demonstrateMarkAndSweep();
// After the function returns, the stack frame is gone
// and no roots reference the circular objects

Generational Garbage Collection

Modern JavaScript engines use a sophisticated approach called generational garbage collection, based on the empirical observation known as the "generational hypothesis": most objects die young. In other words, the majority of objects become unreachable shortly after they are created.

The V8 engine (used in Chrome and Node.js) divides the heap into two main generations:

Young Generation (Nursery): Newly created objects are allocated here. This space is small (typically 1-8 MB) and is collected frequently using a fast algorithm called Scavenge (a semi-space copying collector). The young generation is divided into two equal-sized semi-spaces. Objects are allocated in one semi-space, and when it fills up, surviving objects are copied to the other semi-space. Objects that survive two scavenge cycles are promoted to the old generation.

Old Generation: Objects that have survived multiple young generation collections are promoted here. This space is much larger and is collected less frequently using a variation of mark-and-sweep called Mark-Compact. Because old generation collections are more expensive, the engine tries to minimize them. The Mark-Compact algorithm not only frees unreachable objects but also compacts the remaining objects to reduce memory fragmentation.

Example: Object Lifetime Patterns

// SHORT-LIVED OBJECTS (collected quickly in young generation)
function processData(items) {
    // These temporary objects die when the function returns
    let temp = items.map(item => ({
        id: item.id,
        processed: true
    }));
    let result = temp.filter(item => item.processed);
    return result.length;
    // 'temp' and intermediate arrays are collected quickly
}

// LONG-LIVED OBJECTS (promoted to old generation)
const appCache = new Map();
const config = Object.freeze({
    apiUrl: 'https://api.example.com',
    timeout: 5000,
    retries: 3
});

// These persist for the application's lifetime
// and get promoted to old generation after surviving
// multiple young generation GC cycles
Tip: Write code that creates short-lived, temporary objects rather than long-lived mutable objects whenever possible. Short-lived objects are collected efficiently in the young generation, while long-lived objects accumulate in the old generation and make major GC pauses longer.

Common Memory Leaks

A memory leak occurs when memory that is no longer needed is not released because the program maintains a reference to it, preventing the garbage collector from reclaiming it. Over time, memory leaks cause your application to consume more and more memory, leading to slowdowns, unresponsiveness, and eventually crashes. Here are the most common patterns that cause memory leaks in JavaScript.

1. Accidental Global Variables

When you assign a value to an undeclared variable, JavaScript creates it as a property of the global object. These variables persist for the entire lifetime of the application and are never garbage collected unless explicitly removed.

Example: Accidental Globals

// BAD: Accidental global variable
function processRequest(data) {
    // Missing 'let', 'const', or 'var' keyword
    // This creates a global variable!
    results = JSON.parse(data);
    processedCount = results.length;
}

// BAD: 'this' refers to global object in non-strict mode
function setName() {
    this.username = 'admin'; // window.username in browsers
}
setName();

// GOOD: Use strict mode to prevent accidental globals
'use strict';
function safeProcess(data) {
    // This now throws a ReferenceError instead of
    // creating a global variable
    // results = JSON.parse(data); // ReferenceError!
    const results = JSON.parse(data); // Correct
    return results;
}

2. Forgotten Timers and Intervals

Timers created with setInterval continue to run indefinitely unless explicitly cleared. If the timer callback references objects that should otherwise be garbage collected, those objects will be kept alive for as long as the timer runs.

Example: Timer Leaks and Fixes

// BAD: Forgotten interval that keeps data alive
function startPolling() {
    let hugeData = new Array(1000000).fill('data');

    setInterval(() => {
        // This closure keeps 'hugeData' alive forever
        // because the interval never stops
        console.log(hugeData.length);
    }, 1000);
}

// GOOD: Store the interval ID and clear it when done
function startPollingFixed() {
    let hugeData = new Array(1000000).fill('data');

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

    // Clear after 10 seconds
    setTimeout(() => {
        clearInterval(intervalId);
        hugeData = null; // Allow GC to collect the data
    }, 10000);

    return intervalId; // Return ID so caller can clear if needed
}

// GOOD: In a component lifecycle, always clean up
class DataPoller {
    start() {
        this.data = fetchLargeDataset();
        this.intervalId = setInterval(() => {
            this.process();
        }, 5000);
    }

    stop() {
        clearInterval(this.intervalId);
        this.intervalId = null;
        this.data = null;
    }
}

3. Detached DOM Nodes

A detached DOM node is an element that has been removed from the document tree but is still referenced by JavaScript code. Since the JavaScript code holds a reference, the garbage collector cannot free the memory occupied by the node and all of its children.

Example: Detached DOM Node Leaks

// BAD: Holding references to removed DOM nodes
let detachedNodes = [];

function addAndRemoveElement() {
    let div = document.createElement('div');
    div.innerHTML = '<p>Large content here...</p>'.repeat(1000);
    document.body.appendChild(div);

    // Store reference before removing
    detachedNodes.push(div);

    // Remove from DOM, but the reference in the array
    // prevents garbage collection
    document.body.removeChild(div);
}

// GOOD: Clean up references when removing nodes
function addAndRemoveElementFixed() {
    let div = document.createElement('div');
    div.innerHTML = '<p>Content</p>';
    document.body.appendChild(div);
    document.body.removeChild(div);
    div = null; // Remove the reference, allow GC
}

// GOOD: Use WeakRef for optional DOM references
let weakNodeRef = null;

function trackElement() {
    let div = document.createElement('div');
    document.body.appendChild(div);
    weakNodeRef = new WeakRef(div);
    // Later, check if the node still exists:
    // const node = weakNodeRef.deref();
    // if (node) { /* still alive */ }
}

4. Closures Holding References

Closures are a powerful JavaScript feature, but they can inadvertently keep large objects alive if the closure captures variables from its outer scope that reference large data structures.

Example: Closure Memory Leaks

// BAD: Closure keeps entire outer scope alive
function createHandler() {
    let largeArray = new Array(1000000).fill('x');
    let importantValue = 42;

    // This closure only uses 'importantValue' but
    // the engine may keep 'largeArray' alive too
    return function handler() {
        return importantValue;
    };
}

// GOOD: Nullify large data before returning closure
function createHandlerFixed() {
    let largeArray = new Array(1000000).fill('x');
    let importantValue = computeFrom(largeArray);

    // Release the large array before creating the closure
    largeArray = null;

    return function handler() {
        return importantValue;
    };
}

function computeFrom(arr) {
    return arr.length;
}

5. Event Listeners Not Removed

Event listeners that are added but never removed prevent the garbage collector from freeing the listener function and any variables it captures through closures. This is especially problematic in single-page applications where elements are dynamically created and destroyed.

Example: Event Listener Leaks

// BAD: Adding listeners without removing them
function setupHandlers() {
    let data = loadHugeDataset();

    // Every time setupHandlers is called, a NEW listener
    // is added without removing the old one
    document.getElementById('btn').addEventListener('click', () => {
        processData(data);
    });
}

// GOOD: Use named functions and remove listeners
function setupHandlersFixed() {
    let data = loadHugeDataset();

    function handleClick() {
        processData(data);
    }

    const btn = document.getElementById('btn');
    btn.addEventListener('click', handleClick);

    // Return a cleanup function
    return function cleanup() {
        btn.removeEventListener('click', handleClick);
        data = null;
    };
}

// GOOD: Use AbortController for bulk cleanup
function setupMultipleHandlers() {
    const controller = new AbortController();

    document.getElementById('btn1').addEventListener('click', handler1, {
        signal: controller.signal
    });
    document.getElementById('btn2').addEventListener('click', handler2, {
        signal: controller.signal
    });
    document.getElementById('btn3').addEventListener('keydown', handler3, {
        signal: controller.signal
    });

    // Remove ALL listeners at once
    return function cleanup() {
        controller.abort();
    };
}

// GOOD: Use { once: true } for one-time handlers
document.getElementById('submitBtn').addEventListener('click', () => {
    submitForm();
}, { once: true }); // Automatically removed after first trigger

Identifying Memory Leaks with Chrome DevTools

Chrome DevTools provides powerful tools for identifying and diagnosing memory leaks. The Memory tab offers three main profiling types that help you understand how your application uses memory.

Heap Snapshots

A heap snapshot captures the complete state of the JavaScript heap at a specific point in time. It shows every object in memory, its size, what references it, and its distance from the GC root. The most effective technique is the "three snapshot" method: take a snapshot before performing an action, perform the action, take another snapshot, undo the action, and take a third snapshot. Objects that appear in snapshot 2 but not in snapshots 1 or 3 are likely leaking.

Allocation Timeline

The allocation timeline records memory allocations over a period of time, showing you when and where objects are being created. Blue bars represent allocations that are still alive at the end of the recording, while gray bars represent allocations that were garbage collected. A continuously growing pattern of blue bars indicates a memory leak.

Allocation Sampling

Allocation sampling is a lightweight profiling mode that periodically samples the call stack during allocations. It produces less overhead than the allocation timeline, making it suitable for profiling in production-like environments. It shows which functions are responsible for the most memory allocations.

Example: Detecting Leaks with the Performance API

// Use performance.memory to monitor memory usage
// (Chrome/Edge only, requires --enable-precise-memory-info flag)
function checkMemory() {
    if (performance.memory) {
        console.log('JS Heap Size Limit:',
            formatBytes(performance.memory.jsHeapSizeLimit));
        console.log('Total JS Heap Size:',
            formatBytes(performance.memory.totalJSHeapSize));
        console.log('Used JS Heap Size:',
            formatBytes(performance.memory.usedJSHeapSize));
    }
}

function formatBytes(bytes) {
    if (bytes === 0) return '0 Bytes';
    const k = 1024;
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

// Monitor memory over time to detect leaks
function monitorForLeaks(intervalMs, durationMs) {
    const readings = [];
    const id = setInterval(() => {
        if (performance.memory) {
            readings.push({
                time: Date.now(),
                used: performance.memory.usedJSHeapSize
            });
        }
    }, intervalMs);

    setTimeout(() => {
        clearInterval(id);
        analyzeReadings(readings);
    }, durationMs);
}

function analyzeReadings(readings) {
    if (readings.length < 2) return;
    const first = readings[0].used;
    const last = readings[readings.length - 1].used;
    const growth = last - first;
    console.log('Memory growth:', formatBytes(growth));
    if (growth > 10 * 1024 * 1024) { // More than 10MB
        console.warn('Potential memory leak detected!');
    }
}
Tip: When using heap snapshots, look for objects in the "Detached" filter -- these show DOM elements that are no longer in the document tree but are still held in memory by JavaScript references. This is one of the fastest ways to find common leaks in web applications.

WeakRef and FinalizationRegistry

ES2021 introduced WeakRef and FinalizationRegistry, two features that give developers more control over how objects interact with garbage collection. These are advanced tools that should be used sparingly and only when simpler patterns are insufficient.

WeakRef creates a weak reference to an object. Unlike a normal (strong) reference, a weak reference does not prevent the garbage collector from collecting the referenced object. You can attempt to dereference a WeakRef using the deref() method, which returns the object if it still exists or undefined if it has been collected.

FinalizationRegistry allows you to register a callback that is invoked after an object has been garbage collected. This can be useful for cleaning up external resources associated with an object, such as file handles, network connections, or entries in a cache.

Example: WeakRef and FinalizationRegistry

// WeakRef for an efficient cache
class WeakCache {
    constructor() {
        this.cache = new Map();
        this.registry = new FinalizationRegistry((key) => {
            // Called after the cached object is garbage collected
            // Clean up the Map entry
            const ref = this.cache.get(key);
            if (ref && !ref.deref()) {
                this.cache.delete(key);
                console.log('Cache entry cleaned up for key:', key);
            }
        });
    }

    set(key, value) {
        // Store a weak reference instead of the actual object
        const ref = new WeakRef(value);
        this.cache.set(key, ref);
        // Register for cleanup notification
        this.registry.register(value, key);
    }

    get(key) {
        const ref = this.cache.get(key);
        if (ref) {
            const value = ref.deref();
            if (value !== undefined) {
                return value; // Object still alive
            }
            // Object was collected, clean up
            this.cache.delete(key);
        }
        return undefined; // Cache miss
    }

    get size() {
        return this.cache.size;
    }
}

// Usage
const cache = new WeakCache();

function loadUserProfile(userId) {
    let cached = cache.get(userId);
    if (cached) {
        return cached; // Use cached version
    }

    // Fetch and cache the profile
    const profile = { id: userId, name: 'User ' + userId, data: '...' };
    cache.set(userId, profile);
    return profile;
}
Warning: The timing of garbage collection is unpredictable. A FinalizationRegistry callback might be called immediately after the object is collected, much later, or potentially never (for example, if the program exits before a GC cycle runs). Never rely on FinalizationRegistry for critical cleanup tasks. Use explicit cleanup methods like close() or dispose() patterns instead, and treat FinalizationRegistry as a safety net for when explicit cleanup is missed.

Memory-Efficient Coding Patterns

Beyond avoiding leaks, there are many patterns that help you use memory more efficiently, leading to better performance and lower resource consumption.

Object Pooling

Instead of creating and destroying many short-lived objects, reuse them from a pre-allocated pool. This reduces GC pressure and allocation overhead, which is particularly important in performance-critical code like game loops and animation frames.

Example: Object Pooling Pattern

class ObjectPool {
    constructor(factory, reset, initialSize = 10) {
        this.factory = factory;
        this.reset = reset;
        this.pool = [];

        // Pre-allocate objects
        for (let i = 0; i < initialSize; i++) {
            this.pool.push(this.factory());
        }
    }

    acquire() {
        if (this.pool.length > 0) {
            return this.pool.pop();
        }
        // Pool exhausted, create a new object
        return this.factory();
    }

    release(obj) {
        this.reset(obj); // Reset state for reuse
        this.pool.push(obj);
    }
}

// Usage: Particle system for animations
const particlePool = new ObjectPool(
    () => ({ x: 0, y: 0, vx: 0, vy: 0, life: 0, active: false }),
    (p) => { p.x = 0; p.y = 0; p.vx = 0; p.vy = 0; p.life = 0; p.active = false; },
    100
);

function spawnParticle(x, y) {
    const particle = particlePool.acquire();
    particle.x = x;
    particle.y = y;
    particle.vx = Math.random() * 2 - 1;
    particle.vy = Math.random() * -3;
    particle.life = 60;
    particle.active = true;
    return particle;
}

function retireParticle(particle) {
    particle.active = false;
    particlePool.release(particle);
}

Use Typed Arrays for Large Numeric Data

Typed arrays like Float64Array, Int32Array, and Uint8Array store data in contiguous memory buffers, which is far more memory-efficient than regular arrays for numeric data.

Example: Typed Arrays vs Regular Arrays

// BAD: Regular array of numbers (each element is a boxed object)
const regularArray = new Array(1000000);
for (let i = 0; i < 1000000; i++) {
    regularArray[i] = Math.random();
}
// Approximate memory: ~8MB+ (each number is a heap object)

// GOOD: Typed array (contiguous memory buffer)
const typedArray = new Float64Array(1000000);
for (let i = 0; i < 1000000; i++) {
    typedArray[i] = Math.random();
}
// Approximate memory: ~8MB (exactly 8 bytes per number, no overhead)

// Use ArrayBuffer for binary data
const buffer = new ArrayBuffer(1024); // 1KB buffer
const view = new DataView(buffer);
view.setInt32(0, 42);       // Write a 32-bit integer at byte 0
view.setFloat64(4, 3.14);   // Write a 64-bit float at byte 4

WeakMap and WeakSet for Metadata

When you need to associate metadata with objects without preventing garbage collection, use WeakMap and WeakSet. Unlike regular Maps and Sets, weak collections allow their keys to be garbage collected when there are no other references to them.

Example: WeakMap for Private Data

// Store private data associated with DOM elements
const elementData = new WeakMap();

function trackElement(element) {
    elementData.set(element, {
        clickCount: 0,
        firstSeen: Date.now(),
        interactions: []
    });
}

function recordClick(element) {
    const data = elementData.get(element);
    if (data) {
        data.clickCount++;
        data.interactions.push({ type: 'click', time: Date.now() });
    }
}

// When the DOM element is removed and no JS references remain,
// both the element AND its associated data in the WeakMap
// are automatically garbage collected. No cleanup needed!

The performance.memory API

The performance.memory API provides a programmatic way to monitor JavaScript heap memory usage at runtime. While currently only available in Chromium-based browsers, it is invaluable for building memory monitoring dashboards and automated leak detection systems.

Example: Building a Memory Monitor

class MemoryMonitor {
    constructor(options = {}) {
        this.sampleInterval = options.sampleInterval || 5000;
        this.maxSamples = options.maxSamples || 100;
        this.warningThresholdMB = options.warningThresholdMB || 100;
        this.samples = [];
        this.intervalId = null;
    }

    start() {
        if (!performance.memory) {
            console.warn('performance.memory is not available');
            return;
        }

        this.intervalId = setInterval(() => {
            this.takeSample();
        }, this.sampleInterval);

        this.takeSample(); // Take initial sample
    }

    stop() {
        if (this.intervalId) {
            clearInterval(this.intervalId);
            this.intervalId = null;
        }
    }

    takeSample() {
        const sample = {
            timestamp: Date.now(),
            usedHeap: performance.memory.usedJSHeapSize,
            totalHeap: performance.memory.totalJSHeapSize,
            heapLimit: performance.memory.jsHeapSizeLimit
        };

        this.samples.push(sample);

        if (this.samples.length > this.maxSamples) {
            this.samples.shift();
        }

        const usedMB = sample.usedHeap / (1024 * 1024);
        if (usedMB > this.warningThresholdMB) {
            console.warn('High memory usage: ' + usedMB.toFixed(2) + 'MB');
        }
    }

    getGrowthRate() {
        if (this.samples.length < 2) return 0;
        const first = this.samples[0];
        const last = this.samples[this.samples.length - 1];
        const timeDiff = (last.timestamp - first.timestamp) / 1000;
        const memDiff = last.usedHeap - first.usedHeap;
        return memDiff / timeDiff; // bytes per second
    }

    report() {
        const rate = this.getGrowthRate();
        const current = this.samples[this.samples.length - 1];
        return {
            currentUsedMB: (current.usedHeap / (1024 * 1024)).toFixed(2),
            growthRateKBPerSec: (rate / 1024).toFixed(2),
            isLeaking: rate > 1024 // More than 1KB/sec sustained growth
        };
    }
}

// Usage
const monitor = new MemoryMonitor({ warningThresholdMB: 50 });
monitor.start();

// Check periodically
setTimeout(() => {
    console.log(monitor.report());
    monitor.stop();
}, 60000);
Note: The performance.memory API returns updated values only after a garbage collection cycle. For more precise measurements, Chrome DevTools offers the "Collect garbage" button (trash can icon) in the Performance and Memory tabs, which forces a GC cycle before taking a measurement.

Summary of Best Practices

Memory management in JavaScript requires awareness rather than manual control. Here are the key takeaways from this lesson that you should apply in every project:

  • Always use 'use strict' or ES modules to prevent accidental global variables.
  • Clear timers and intervals with clearTimeout and clearInterval when they are no longer needed.
  • Remove event listeners when elements are destroyed, or use AbortController for bulk cleanup.
  • Nullify references to large objects when you no longer need them.
  • Avoid storing references to DOM nodes that have been removed from the document.
  • Use WeakMap, WeakSet, and WeakRef when you need associations that should not prevent garbage collection.
  • Profile your application regularly using Chrome DevTools Memory tab and heap snapshots.
  • Consider object pooling for high-frequency object creation and destruction scenarios.
  • Use typed arrays for large collections of numeric data.
  • Monitor memory usage in production with the performance.memory API where available.

Practice Exercise

Build a small single-page application simulator that demonstrates and fixes memory leaks. Create a page with four buttons: "Add Component" should dynamically create a DOM element with an event listener and a setInterval timer that updates its content every second. "Remove Component" should remove the most recently added element from the DOM. "Check Memory" should display the number of active timers and event listeners. "Fix Leaks" should properly clean up all detached nodes, clear all orphaned timers, and remove all orphaned event listeners. First, implement the "leaky" version where Remove Component only calls removeChild without cleaning up timers or listeners. Open Chrome DevTools Memory tab and take a heap snapshot after adding and removing 20 components -- observe the growing memory. Then implement the "fixed" version where Remove Component properly calls clearInterval, removeEventListener, and nullifies references before removing the node. Take another heap snapshot after the same sequence and compare the two snapshots to see the difference proper cleanup makes.