Hunt and Fix JavaScript Memory Leaks

Detect and fix three classic JavaScript memory leaks using Chrome DevTools.

Introduction

30 Second Summary

Ever noticed a website getting slower and slower the longer you use it? That's often a memory leak: the program keeps hoarding data it no longer needs, like a room filling up with furniture nobody uses.

In this project, you will build an interactive Memory Leak Lab that deliberately creates three common JavaScript memory leak patterns. You'll then use Chrome DevTools to detect, diagnose, and fix each one.

What You'll Build

A single-page interactive lab with three experiment panels, each demonstrating a different memory leak pattern with buttons to trigger and fix each leak.

By the end of this project, you'll have:

  • A working Memory Leak Lab web page with three interactive experiments demonstrating detached DOM nodes, forgotten event listeners, and unbounded array growth.
  • Hands-on experience using the Chrome DevTools Memory panel to take heap snapshots and prove memory leaks exist.
  • A clear mental model of how JavaScript garbage collection works (mark-and-sweep, reachability from roots) that you can explain in a technical interview.
  • Secret Mission: Add a fourth experiment that detects a forgotten setInterval timer leak using the Allocation Timeline and fixes it with clearInterval.

Do I need to know JavaScript?

Nope. This project teaches JavaScript fundamentals inline as you go. All you need is basic computer literacy and a willingness to experiment.

Before We Start

Before We Start...

Set Up Your Workspace

Before you can hunt memory leaks, you need a lab to experiment in. Your Memory Leak Lab is a single web page with three interactive experiments, each designed to trigger a specific type of JavaScript memory leak.

In this step, you will create the project files, build the lab interface, and confirm that Chrome DevTools is ready for memory profiling.

In this step, get ready to:
  • Create a project folder with three files.
  • Build the HTML and CSS for three memory leak experiments.
  • Confirm Chrome DevTools Memory panel is accessible.
Create your project folder and files

Every project needs a home folder. You will create one on your Desktop so it is easy to find.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (backtick key, next to the 1 key on your keyboard).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir memory-leak-lab
cd memory-leak-lab
  • Open this folder in VS Code by running:
code .

code command not recognized?

If the code command does not work, open the folder manually instead.

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to Desktop and select the memory-leak-lab folder, then click Open.

If VS Code asks whether you trust the authors of this folder, click Yes, I trust the authors.

VS Code reopens with your empty project folder. Now create three files.

  • In the Explorer sidebar on the left, hover over the folder name MEMORY-LEAK-LAB.
  • Click the New File icon (a page with a plus sign).
  • Type index.html and press Enter.
  • Repeat to create a second file named style.css.
  • Repeat to create a third file named leaks.js.
  • Confirm the files exist by running:
ls

You should see index.html, style.css, and leaks.js in the output.

Don't see all three files?

Make sure you are creating files (page icon with a plus), not folders (folder icon with a plus). If you accidentally created a folder, right-click it in the Explorer sidebar and select Delete, then try again.

help me create files in VS Code

Build the HTML skeleton and add styling

Your lab needs a user interface. The HTML creates three experiment panels, each with a heading, a description, two buttons, and a status readout.

  • Click on index.html in the Explorer sidebar to open it.
  • Paste the following HTML into the file:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Memory Leak Lab</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <h1>Memory Leak Lab</h1>
    <p class="intro">Click the buttons below to trigger memory leaks, then use Chrome DevTools Memory panel to detect them. Fix each leak and verify with a new heap snapshot.</p>

    <section class="experiment">
        <h2>Experiment 1: Detached DOM Nodes</h2>
        <p class="description">Elements removed from the page but still referenced in JavaScript cannot be garbage collected.</p>
        <button id="create-detached">Create Leak</button>
        <button id="fix-detached">Fix Leak</button>
        <p id="status-detached" class="status">Status: Ready</p>
    </section>

    <section class="experiment">
        <h2>Experiment 2: Forgotten Event Listeners</h2>
        <p class="description">Event listeners that pile up without removal keep their closures (and captured data) alive forever.</p>
        <button id="create-listeners">Create Leak</button>
        <button id="fix-listeners">Fix Leak</button>
        <p id="status-listeners" class="status">Status: Ready</p>
    </section>

    <section class="experiment">
        <h2>Experiment 3: Unbounded Array Growth</h2>
        <p class="description">A global array that grows without limit is always reachable from the window object, so nothing gets collected.</p>
        <button id="create-array">Create Leak</button>
        <button id="fix-array">Fix Leak</button>
        <p id="status-array" class="status">Status: Ready</p>
    </section>

    <script src="leaks.js"></script>
</body>
</html>

What does this HTML do?

  • The <link> tag connects your stylesheet so the page looks styled.
  • Each <section class="experiment"> is one experiment panel with its own heading, description, two buttons, and a status line.
  • The id attributes on buttons and status paragraphs let JavaScript target specific elements later.
  • The <script> tag at the bottom loads leaks.js, which is where your leak experiments will live.
  • Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Now add the dark-theme styling that makes the lab look polished.

  • Click on style.css in the Explorer sidebar to open it.
  • Paste the following CSS into the file:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: system-ui, -apple-system, sans-serif;
    max-width: 800px;
    margin: 0 auto;
    padding: 2rem;
    background: #1a1a2e;
    color: #e0e0e0;
}

h1 {
    color: #00d4ff;
    margin-bottom: 0.5rem;
}

.intro {
    color: #a0a0a0;
    margin-bottom: 2rem;
}

.experiment {
    background: #16213e;
    border: 1px solid #0f3460;
    border-radius: 8px;
    padding: 1.5rem;
    margin-bottom: 1.5rem;
}

.experiment h2 {
    color: #00d4ff;
    margin-bottom: 0.5rem;
    font-size: 1.2rem;
}

.description {
    color: #a0a0a0;
    font-size: 0.9rem;
    margin-bottom: 1rem;
}

button {
    background: #0f3460;
    color: #00d4ff;
    border: 1px solid #00d4ff;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    cursor: pointer;
    margin-right: 0.5rem;
    font-size: 0.9rem;
}

button:hover {
    background: #00d4ff;
    color: #1a1a2e;
}

.status {
    margin-top: 1rem;
    padding: 0.5rem;
    background: #0a0a1a;
    border-radius: 4px;
    font-family: monospace;
    font-size: 0.85rem;
}

What does this CSS do?

  • The universal selector (*) resets spacing so every browser starts from the same baseline.
  • The body rule sets a dark background (#1a1a2e) and centers content at a max width of 800px.
  • Each .experiment panel gets a slightly lighter card background with rounded corners and a subtle border.
  • Buttons use a cyan accent (#00d4ff) that inverts on hover for clear interactivity.
  • Save style.css by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Make sure both index.html and style.css are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Memory Leak Lab</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <h1>Memory Leak Lab</h1>
    <p class="intro">Click the buttons below to trigger memory leaks, then use Chrome DevTools Memory panel to detect them. Fix each leak and verify with a new heap snapshot.</p>

    <section class="experiment">
        <h2>Experiment 1: Detached DOM Nodes</h2>
        <p class="description">Elements removed from the page but still referenced in JavaScript cannot be garbage collected.</p>
        <button id="create-detached">Create Leak</button>
        <button id="fix-detached">Fix Leak</button>
        <p id="status-detached" class="status">Status: Ready</p>
    </section>

    <section class="experiment">
        <h2>Experiment 2: Forgotten Event Listeners</h2>
        <p class="description">Event listeners that pile up without removal keep their closures (and captured data) alive forever.</p>
        <button id="create-listeners">Create Leak</button>
        <button id="fix-listeners">Fix Leak</button>
        <p id="status-listeners" class="status">Status: Ready</p>
    </section>

    <section class="experiment">
        <h2>Experiment 3: Unbounded Array Growth</h2>
        <p class="description">A global array that grows without limit is always reachable from the window object, so nothing gets collected.</p>
        <button id="create-array">Create Leak</button>
        <button id="fix-array">Fix Leak</button>
        <p id="status-array" class="status">Status: Ready</p>
    </section>

    <script src="leaks.js"></script>
</body>
</html>

Here is the complete style.css:

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: system-ui, -apple-system, sans-serif;
    max-width: 800px;
    margin: 0 auto;
    padding: 2rem;
    background: #1a1a2e;
    color: #e0e0e0;
}

h1 {
    color: #00d4ff;
    margin-bottom: 0.5rem;
}

.intro {
    color: #a0a0a0;
    margin-bottom: 2rem;
}

.experiment {
    background: #16213e;
    border: 1px solid #0f3460;
    border-radius: 8px;
    padding: 1.5rem;
    margin-bottom: 1.5rem;
}

.experiment h2 {
    color: #00d4ff;
    margin-bottom: 0.5rem;
    font-size: 1.2rem;
}

.description {
    color: #a0a0a0;
    font-size: 0.9rem;
    margin-bottom: 1rem;
}

button {
    background: #0f3460;
    color: #00d4ff;
    border: 1px solid #00d4ff;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    cursor: pointer;
    margin-right: 0.5rem;
    font-size: 0.9rem;
}

button:hover {
    background: #00d4ff;
    color: #1a1a2e;
}

.status {
    margin-top: 1rem;
    padding: 0.5rem;
    background: #0a0a1a;
    border-radius: 4px;
    font-family: monospace;
    font-size: 0.85rem;
}
Open your lab in Chrome and find the Memory panel

Chrome DevTools has a dedicated Memory panel for profiling heap usage. You need to confirm it is accessible before the experiments begin.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Google Chrome and press Enter to open it.
  • In Chrome, press Cmd+O (macOS) or Ctrl+O (Windows) to open the file picker.
  • Navigate to Desktop > memory-leak-lab and select index.html. Click Open.

You should see a dark-themed page titled "Memory Leak Lab" with three experiment cards, each showing a Create Leak button and a Fix Leak button.

  • Open DevTools by pressing Cmd+Option+I (macOS) or F12 (Windows).
  • Open the DevTools Command Menu by pressing Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows).
  • Type memory into the command input.
  • Select Show Memory from the list.

What is the Memory panel?

The Memory panel is where you take heap snapshots and record allocation timelines. A heap snapshot captures every object in memory at a point in time, letting you compare snapshots to find objects that should have been freed but were not.

You will use it throughout this project to prove that leaks exist and confirm your fixes work.

  • Confirm you see the Heap snapshot radio button in the Memory panel. It should be selected by default.

Don't see the Memory panel?

If the Command Menu did not show "Show Memory", try the alternative path: in DevTools, look for a >> (more panels) icon in the top tab bar. Click it and select Memory from the dropdown.

help me find the Memory panel in Chrome DevTools

Your workspace is ready. Next up, you will learn how JavaScript's garbage collector decides what to keep and what to free, then write your first intentional memory leak.

Create and Detect the Detached DOM Nodes Leak

In the previous step, you set up your workspace with three files and confirmed the Memory panel is visible in Chrome DevTools. Now it's time to understand why memory leaks happen and create your first one on purpose.

JavaScript automatically manages memory for you through a process called garbage collection. When garbage collection fails to reclaim objects that are no longer needed, memory grows indefinitely. In this step, you'll learn how garbage collection decides what to keep, write code that tricks it into holding onto useless objects, and then prove the leak exists using a heap snapshot.

In this step, get ready to:
  • Learn how JavaScript's mark-and-sweep garbage collector works.
  • Write a detached DOM nodes leak in leaks.js.
  • Detect the leak with heap snapshots in Chrome DevTools.
Learn how garbage collection works

Every time your JavaScript code creates an object, array, or DOM element, the browser allocates memory for it. But memory is finite. The browser needs a way to reclaim memory from objects you no longer need.

This cleanup process is called garbage collection, and Chrome's V8 engine uses an algorithm called mark-and-sweep.

What is mark-and-sweep?

The garbage collector starts from "roots" (the global window object, the current call stack, and active closures). It visits every object reachable from those roots and marks it as "alive."

Once marking is complete, any object that was NOT marked is unreachable. The garbage collector sweeps (frees) those unmarked objects, reclaiming their memory.

A memory leak happens when an object is no longer needed by your program, but is still reachable from a root. The garbage collector sees it as alive and refuses to free it.

Why does reachability matter more than references?

Being referenced is not the same as being reachable. A group of objects can reference each other in a circle, but if none of them connect back to a root, the garbage collector reclaims them all.

The key insight: if you accidentally keep a path from a root (like a global variable) to objects you no longer need, those objects will never be freed.

Write the detached DOM nodes leak

A "detached DOM node" is an element that has been removed from the visible page but still has a JavaScript reference pointing to it. Because the reference keeps it reachable from a root, the garbage collector cannot reclaim it.

  • Switch back to VS Code and open leaks.js.
  • Add the experiment header, global array, and the createDetachedLeak function by pasting this code:
// === EXPERIMENT 1: Detached DOM Nodes ===

// This array holds references to removed DOM elements
let detachedNodes = [];

function createDetachedLeak() {
    // Create 100 DOM elements, add them to the page, then remove them
    for (let i = 0; i < 100; i++) {
        const div = document.createElement('div');
        div.textContent = 'Leaked node ' + i + ' - ' + 'x'.repeat(10000);
        document.body.appendChild(div);
        document.body.removeChild(div);
        // The div is gone from the page, but this array still points to it
        detachedNodes.push(div);
    }
    document.getElementById('status-detached').textContent =
        'Status: Leak created! ' + detachedNodes.length + ' detached nodes held in memory. Take a heap snapshot and filter by "Detached".';
}

What does this code do?

  • detachedNodes is a global array. Because it lives on the global scope (reachable from window), anything stored inside it can never be garbage collected.
  • The loop creates 100 div elements, each filled with 10,000 characters to make them large enough to show up clearly in a heap snapshot.
  • Each div is appended to the page and immediately removed. Normally, removed elements would be garbage collected. But detachedNodes.push(div) keeps a reference alive, so the garbage collector considers them reachable and keeps them in memory.
  • Below the createDetachedLeak function, add the fix function and the event listener wiring by pasting this code:

function fixDetachedLeak() {
    // Clear the array so nothing references the detached nodes anymore
    detachedNodes = [];
    document.getElementById('status-detached').textContent =
        'Status: Fixed! Array cleared. Detached nodes are now eligible for garbage collection. Take a new snapshot to confirm.';
}

// Wire up the buttons to our functions
document.getElementById('create-detached').addEventListener('click', createDetachedLeak);
document.getElementById('fix-detached').addEventListener('click', fixDetachedLeak);

What does the fix do?

Setting detachedNodes = [] replaces the old array with a fresh empty one. The old array (and all 100 detached div elements it held) is no longer reachable from any root. The garbage collector can now sweep them all.

The two addEventListener calls connect the HTML buttons to the functions you just wrote. Clicking Create Leak triggers createDetachedLeak, and clicking Fix Leak triggers fixDetachedLeak.

  • Save leaks.js.
  • Switch to Chrome and refresh the page.
  • Click the Create Leak button under Experiment 1.
  • You should see the status text update to show 100 detached nodes held in memory.

Status text not updating?

  • Make sure you saved leaks.js and refreshed the browser page.
  • Open the Console tab in DevTools and check for any red error messages. A typo in an element ID like status-detached will cause a null reference error.
  • Confirm that leaks.js is in the same folder as index.html.

Help me debug why my Create Leak button isn't working

✔️ Awesome, I've got everything!

Great. Make sure you've saved leaks.js before moving on.

ⓧ I'd like to double check the full code

Here is the complete leaks.js file so far:

// === EXPERIMENT 1: Detached DOM Nodes ===

let detachedNodes = [];

function createDetachedLeak() {
    for (let i = 0; i < 100; i++) {
        const div = document.createElement('div');
        div.textContent = 'Leaked node ' + i + ' - ' + 'x'.repeat(10000);
        document.body.appendChild(div);
        document.body.removeChild(div);
        detachedNodes.push(div);
    }
    document.getElementById('status-detached').textContent =
        'Status: Leak created! ' + detachedNodes.length + ' detached nodes held in memory. Take a heap snapshot and filter by "Detached".';
}

function fixDetachedLeak() {
    detachedNodes = [];
    document.getElementById('status-detached').textContent =
        'Status: Fixed! Array cleared. Detached nodes are now eligible for garbage collection. Take a new snapshot to confirm.';
}

document.getElementById('create-detached').addEventListener('click', createDetachedLeak);
document.getElementById('fix-detached').addEventListener('click', fixDetachedLeak);
Take heap snapshots to detect the leak

Your code is working, but how do you prove a memory leak exists? Chrome DevTools lets you take a heap snapshot that shows every object in memory at that moment. By comparing snapshots before and after the leak, you can see exactly what the garbage collector is holding onto.

  • In Chrome, refresh the page to start with a clean state.
  • Switch to the Memory panel in DevTools.
  • Select the Heap snapshot radio button (it should already be selected).
  • Click Take snapshot to capture your baseline. This is snapshot 1 showing memory before the leak.

What is a heap snapshot?

A heap snapshot shows how memory is distributed among your page's JavaScript objects and DOM nodes at a single point in time. It captures everything the garbage collector considers "alive" at that moment.

You can also press Cmd+E (macOS) or Ctrl+E (Windows) to take additional snapshots quickly.

  • Click the Create Leak button on the page (under Experiment 1) three times to trigger the leak multiple times.
  • Return to the Memory panel and click Take snapshot again. This is snapshot 2 showing memory after the leak.
  • Click on Snapshot 2 in the left sidebar to view it.
  • Type Detached in the Class filter input box at the top of the snapshot results.

You should see entries like Detached HTMLDivElement in the filtered results. These are the DOM nodes your code removed from the page but kept referenced in the detachedNodes array.

What do Shallow Size and Retained Size mean?

Shallow Size is the memory the object itself uses. Retained Size is the total memory that would be freed if this object were garbage collected (including everything it references).

A large Retained Size on your detached nodes confirms they are holding onto significant memory that the garbage collector cannot reclaim.

Don't see Detached entries?

  • Make sure you clicked Create Leak at least once before taking snapshot 2.
  • Check that you typed Detached with a capital D in the Class filter. The filter is case-sensitive.
  • Make sure you are viewing Snapshot 2 (the one taken after clicking the leak button), not Snapshot 1.

Help me find detached DOM nodes in my heap snapshot

You've just proven your first memory leak exists using hard evidence from a heap snapshot. Next up, you'll tackle a sneakier leak: event listeners that pile up invisibly and drag large closures along with them.

Detect and Fix the Event Listener Leak

In the last step, you saw how detached DOM nodes stay in memory when JavaScript holds a reference to them. Now you'll explore a subtler leak pattern: event listeners that pile up silently.

Every time you attach an event listener to the document, JavaScript keeps that listener function (and everything captured in its closure) alive in memory. If you add a new listener on every button click but never remove the old ones, memory grows indefinitely.

In this step, get ready to:
  • Write a function that creates leaking event listeners with large closures.
  • Trigger the leak and use heap snapshots to prove memory is growing.
  • Write a fix that stores a handler reference and removes old listeners before adding new ones.
Write the event listener leak function

When a function is defined inside another function, it captures ("closes over") any variables from the outer scope. If that inner function is used as an event listener, the captured data stays in memory for as long as the listener exists.

What is a closure?

A closure is a function that remembers variables from the scope where it was created. Even after the outer function finishes running, the inner function holds onto those variables.

This means if you attach a closure as an event listener, everything it captured stays alive in memory. Multiply that by dozens of listeners and you have a serious leak.

  • Switch back to leaks.js in VS Code.
  • Add the following code below the Experiment 1 event listeners at the bottom of the file:
// === EXPERIMENT 2: Forgotten Event Listeners ===

// Track how many listeners have been added
let listenerCount = 0;
// Will store a reference to the current handler (used in the fix)
let currentHandler = null;

function createListenerLeak() {
    // Each call creates a large array captured by the handler's closure
    const bigData = new Array(100000).fill('listener-leak-data');

    function handler() {
        console.log('mousemove heard, data size:', bigData.length);
    }

    // Add a new listener without removing the old one
    document.addEventListener('mousemove', handler);
    listenerCount++;

    document.getElementById('status-listeners').textContent =
        'Status: Leak created! ' + listenerCount + ' mousemove listeners attached. Each holds a 100,000-item array in its closure.';
}

document.getElementById('create-listeners').addEventListener('click', createListenerLeak);

What does this code do?

Each time you click the button, createListenerLeak creates a 100,000-item array and defines a handler function that captures it.

The handler is attached to document as a mousemove listener. Because the handler is defined fresh every time (a new anonymous function), removeEventListener can never match it later. The old listeners pile up forever.

  • Save leaks.js.
  • Switch to Chrome and refresh the page.
  • Click the Create Leak button in the Experiment 2 section.
  • You should see the status text update to show 1 mousemove listener attached.

Button does nothing?

Make sure you added the code below the Experiment 1 section (after the fixDetachedLeak event listener line), not inside it. Check the browser console for any error messages about undefined functions.

help me debug why my Create Leak button in Experiment 2 is not working

Trigger the leak and take heap snapshots

Now you have a working leak to observe. You will take a baseline snapshot, trigger the leak multiple times, then take a second snapshot to prove memory is growing.

  • Switch to the Memory panel in DevTools.
  • Make sure Heap snapshot is selected as the profiling type.
  • Click Take snapshot to capture your baseline.

Why take a baseline snapshot?

A baseline snapshot records what memory looks like before you trigger anything. When you take a second snapshot later, you can compare the two using the Comparison view to see exactly what grew.

  • Switch back to the page and click the Create Leak button in Experiment 2 five times.
  • The status should show 5 mousemove listeners attached.
  • Return to the Memory panel and click Take snapshot again.
  • In the new snapshot, select Comparison from the dropdown at the top (next to Summary).
  • Compare against Snapshot 1.

You should see new arrays allocated between the two snapshots. Each 100,000-item array is retained because its listener is still attached to the document.

What am I looking for in the Comparison view?

The Comparison view shows objects that were added (positive delta) between two snapshots. Look for entries like (array) or Array with a growing count. Each one represents a 100,000-item array trapped in a listener closure.

The # Delta column shows how many new objects appeared. The Size Delta column shows how much memory those objects consume.

Write the fix and verify memory returns to baseline

The core problem is that anonymous functions cannot be removed with removeEventListener because you need the exact same function reference. The fix stores a named reference so you can remove the old listener before attaching a new one.

  • In leaks.js, find this line near the bottom of your Experiment 2 code:
document.getElementById('create-listeners').addEventListener('click', createListenerLeak);
  • Add the following code directly above that line:
function fixListenerLeak() {
    // Remove the previous handler if one exists
    if (currentHandler) {
        document.removeEventListener('mousemove', currentHandler);
    }

    // Create new data for the replacement listener
    const bigData = new Array(100000).fill('listener-data-fixed');

    // Store the handler reference so it can be removed later
    currentHandler = function handler() {
        console.log('mousemove heard, data size:', bigData.length);
    };

    // Attach the single named listener
    document.addEventListener('mousemove', currentHandler);
    listenerCount = 1;

    document.getElementById('status-listeners').textContent =
        'Status: Fixed! Old listeners cannot be removed (anonymous), but the pattern is corrected. Only 1 named listener remains. In real code, always store a reference to remove later.';
}
  • Now add this line directly below the create-listeners event listener line:
document.getElementById('fix-listeners').addEventListener('click', fixListenerLeak);

What does this fix do?

The fix stores the handler function in currentHandler so it can be passed to removeEventListener later. Before adding a new listener, it removes the old one.

The previously leaked anonymous listeners cannot be recovered (they are already lost). But from this point forward, only one listener exists at a time. This is the key lesson: always store a reference to any listener you plan to remove.

  • Save leaks.js.
  • Switch to Chrome and refresh the page to start fresh.
  • Take a new heap snapshot (this is your new baseline).
  • Click Create Leak in Experiment 2 five times.
  • Click Fix Leak in Experiment 2.
  • Click the Collect garbage button (trash can icon) in the Memory panel to force garbage collection.
  • Take another heap snapshot and compare it with your baseline.

The retained size should be much closer to your baseline now. The fix replaced the pile of anonymous listeners with a single stored handler.

Memory still looks high after the fix?

Make sure you clicked the Collect garbage button before taking the second snapshot. Without forcing garbage collection, freed objects may still appear in the snapshot.

Also confirm you refreshed the page before starting this test. If you did not refresh, the old leaked listeners from your earlier test are still present.

help me understand why my memory is still high after clicking Fix Leak in Experiment 2

✔️ Awesome, I've got everything!

Great. Double check you have saved leaks.js before moving on.

ⓧ I'd like to double check the full code

Here is the complete leaks.js file as it should look at the end of this step:

// === EXPERIMENT 1: Detached DOM Nodes ===

let detachedNodes = [];

function createDetachedLeak() {
    for (let i = 0; i < 100; i++) {
        const div = document.createElement('div');
        div.textContent = 'Leaked node ' + i + ' - ' + 'x'.repeat(10000);
        document.body.appendChild(div);
        document.body.removeChild(div);
        detachedNodes.push(div);
    }
    document.getElementById('status-detached').textContent =
        'Status: Leak created! ' + detachedNodes.length + ' detached nodes held in memory. Take a heap snapshot and filter by "Detached".';
}

function fixDetachedLeak() {
    detachedNodes = [];
    document.getElementById('status-detached').textContent =
        'Status: Fixed! Array cleared. Detached nodes are now eligible for garbage collection. Take a new snapshot to confirm.';
}

document.getElementById('create-detached').addEventListener('click', createDetachedLeak);
document.getElementById('fix-detached').addEventListener('click', fixDetachedLeak);


// === EXPERIMENT 2: Forgotten Event Listeners ===

let listenerCount = 0;
let currentHandler = null;

function createListenerLeak() {
    const bigData = new Array(100000).fill('listener-leak-data');

    function handler() {
        console.log('mousemove heard, data size:', bigData.length);
    }

    document.addEventListener('mousemove', handler);
    listenerCount++;

    document.getElementById('status-listeners').textContent =
        'Status: Leak created! ' + listenerCount + ' mousemove listeners attached. Each holds a 100,000-item array in its closure.';
}

function fixListenerLeak() {
    if (currentHandler) {
        document.removeEventListener('mousemove', currentHandler);
    }

    const bigData = new Array(100000).fill('listener-data-fixed');

    currentHandler = function handler() {
        console.log('mousemove heard, data size:', bigData.length);
    };

    document.addEventListener('mousemove', currentHandler);
    listenerCount = 1;

    document.getElementById('status-listeners').textContent =
        'Status: Fixed! Old listeners cannot be removed (anonymous), but the pattern is corrected. Only 1 named listener remains. In real code, always store a reference to remove later.';
}

document.getElementById('create-listeners').addEventListener('click', createListenerLeak);
document.getElementById('fix-listeners').addEventListener('click', fixListenerLeak);

You now have two leak patterns under your belt. Next up, you will tackle the third pattern: an unbounded array that grows forever because it is directly reachable from the global window object.

Fix the Unbounded Array Growth Leak and Prove It

You have now seen two different leak patterns: objects stuck in memory because of stale references, and closures keeping data alive through piled-up listeners.

This final experiment tackles the simplest yet most common pattern in production code: a global data structure that grows without any eviction strategy. Because a global variable is always reachable from the window object, the garbage collector can never reclaim its contents.

In this step, get ready to:
  • Write an unbounded array leak that simulates a cache growing forever.
  • Detect the growing retained size with a heap snapshot.
  • Fix the leak with a maximum-size cache pattern and prove memory stays flat.
Write the unbounded array growth leak

Imagine a cache that stores data every time a user performs an action, but never removes old entries. Over time, the array holds megabytes of unused strings that the garbage collector cannot touch because the global variable is always reachable.

  • In VS Code, open leaks.js and scroll to the bottom (after the Experiment 2 event listeners).
  • Add the Experiment 3 global variables and the fix function by pasting this code at the end of the file:
// === EXPERIMENT 3: Unbounded Array Growth ===

let dataCache = [];
const MAX_CACHE_SIZE = 5;
let arrayLeakMode = true;

function fixArrayLeak() {
    dataCache = [];
    arrayLeakMode = false;

    document.getElementById('status-array').textContent =
        'Status: Fixed! Cache cleared and limited to ' + MAX_CACHE_SIZE + ' entries. Click "Create Leak" again to see the bounded behavior.';
}

What does this code do?

Three global variables set up the experiment. dataCache is the array that will hold large strings. MAX_CACHE_SIZE defines the cap (5 entries) used by the fix. arrayLeakMode is a boolean that starts as true, meaning the array grows without limit.

The fixArrayLeak function empties the cache and flips the mode to false. Once the mode is off, the next function you write will start evicting old entries.

  • Directly below the fixArrayLeak function, add the entry function and wire up both buttons:
function createArrayEntry() {
    const bigString = 'x'.repeat(1000000);

    if (!arrayLeakMode) {
        if (dataCache.length >= MAX_CACHE_SIZE) {
            dataCache.shift();
        }
    }

    dataCache.push(bigString);

    const mode = arrayLeakMode ? 'LEAKING (no limit)' : 'FIXED (max ' + MAX_CACHE_SIZE + ')';
    document.getElementById('status-array').textContent =
        'Status: [' + mode + '] Cache size: ' + dataCache.length + ' entries.';
}

document.getElementById('create-array').addEventListener('click', createArrayEntry);
document.getElementById('fix-array').addEventListener('click', fixArrayLeak);

What does this code do?

Each time createArrayEntry runs, it creates a 1-million-character string and pushes it onto dataCache. In leak mode, nothing is ever removed, so the array grows indefinitely.

When arrayLeakMode is false (after the fix), the function checks whether the array has reached MAX_CACHE_SIZE and uses shift() to remove the oldest entry before pushing the new one. This keeps the array size flat.

  • Save leaks.js.
  • Switch to Chrome and refresh the page. Click the Create Leak button under Experiment 3 once. You should see the status text update to show the cache size is 1 entry.

Status not updating?

Make sure you saved leaks.js and refreshed the browser. If you see a console error, check that the create-array ID in your HTML matches the getElementById call exactly.

Help me debug why my Experiment 3 button isn't working.

✔️ Awesome, I've got everything!

Great. Double check you have saved leaks.js before moving on.

ⓧ I'd like to double check the full code

// === EXPERIMENT 1: Detached DOM Nodes ===

let detachedNodes = [];

function createDetachedLeak() {
    for (let i = 0; i < 100; i++) {
        const div = document.createElement('div');
        div.textContent = 'Leaked node ' + i + ' - ' + 'x'.repeat(10000);
        document.body.appendChild(div);
        document.body.removeChild(div);
        detachedNodes.push(div);
    }
    document.getElementById('status-detached').textContent =
        'Status: Leak created! ' + detachedNodes.length + ' detached nodes held in memory. Take a heap snapshot and filter by "Detached".';
}

function fixDetachedLeak() {
    detachedNodes = [];
    document.getElementById('status-detached').textContent =
        'Status: Fixed! Array cleared. Detached nodes are now eligible for garbage collection. Take a new snapshot to confirm.';
}

document.getElementById('create-detached').addEventListener('click', createDetachedLeak);
document.getElementById('fix-detached').addEventListener('click', fixDetachedLeak);


// === EXPERIMENT 2: Forgotten Event Listeners ===

let listenerCount = 0;
let currentHandler = null;

function createListenerLeak() {
    const bigData = new Array(100000).fill('listener-leak-data');

    function handler() {
        console.log('mousemove heard, data size:', bigData.length);
    }

    document.addEventListener('mousemove', handler);
    listenerCount++;

    document.getElementById('status-listeners').textContent =
        'Status: Leak created! ' + listenerCount + ' mousemove listeners attached. Each holds a 100,000-item array in its closure.';
}

function fixListenerLeak() {
    if (currentHandler) {
        document.removeEventListener('mousemove', currentHandler);
    }

    const bigData = new Array(100000).fill('listener-data-fixed');

    currentHandler = function handler() {
        console.log('mousemove heard, data size:', bigData.length);
    };

    document.addEventListener('mousemove', currentHandler);
    listenerCount = 1;

    document.getElementById('status-listeners').textContent =
        'Status: Fixed! Old listeners cannot be removed (anonymous), but the pattern is corrected. Only 1 named listener remains. In real code, always store a reference to remove later.';
}

document.getElementById('create-listeners').addEventListener('click', createListenerLeak);
document.getElementById('fix-listeners').addEventListener('click', fixListenerLeak);


// === EXPERIMENT 3: Unbounded Array Growth ===

let dataCache = [];
const MAX_CACHE_SIZE = 5;
let arrayLeakMode = true;

function fixArrayLeak() {
    dataCache = [];
    arrayLeakMode = false;

    document.getElementById('status-array').textContent =
        'Status: Fixed! Cache cleared and limited to ' + MAX_CACHE_SIZE + ' entries. Click "Create Leak" again to see the bounded behavior.';
}

function createArrayEntry() {
    const bigString = 'x'.repeat(1000000);

    if (!arrayLeakMode) {
        if (dataCache.length >= MAX_CACHE_SIZE) {
            dataCache.shift();
        }
    }

    dataCache.push(bigString);

    const mode = arrayLeakMode ? 'LEAKING (no limit)' : 'FIXED (max ' + MAX_CACHE_SIZE + ')';
    document.getElementById('status-array').textContent =
        'Status: [' + mode + '] Cache size: ' + dataCache.length + ' entries.';
}

document.getElementById('create-array').addEventListener('click', createArrayEntry);
document.getElementById('fix-array').addEventListener('click', fixArrayLeak);
Trigger the leak and take a heap snapshot

Now you will prove the leak exists using the same heap snapshot technique from earlier. The dataCache array is a global variable, which means it is directly reachable from the window object. The garbage collector will never reclaim its contents.

  • Switch to Chrome and refresh the page to reset all experiments.
  • In the Memory panel, select Heap snapshot and click Take snapshot to capture a baseline.
  • Click the Create Leak button under Experiment 3 exactly 10 times. Watch the status text count up to 10 entries.
  • Take a second heap snapshot.
  • Select the second snapshot in the left panel. In the Summary view, look at the Retained Size column. Sort by Retained Size (click the column header) to bring the largest entries to the top.
  • Look for (string) or (array) entries with a large retained size. These are the 1 MB strings sitting in your dataCache array.

What is Retained Size?

Retained Size is the total memory that would be freed if an object were deleted along with all objects that become unreachable as a result. It tells you the real cost of keeping something alive in memory.

Sorting by Retained Size surfaces the biggest offenders first. In your case, the dataCache array retains roughly 10 MB (10 strings of 1 million characters each).

Use Comparison view for a clearer picture

Select your second snapshot, then change the view dropdown from Summary to Comparison and pick your first snapshot as the baseline. This highlights only the objects that were added between the two snapshots.

Apply the fix and confirm memory stays flat

The fix limits dataCache to a maximum of 5 entries. When new data arrives, the oldest entry is shifted out. This is a common cache eviction pattern that prevents unbounded growth.

  • Click the Fix Leak button under Experiment 3. The status text confirms the cache was cleared and limited.
  • Click Create Leak 10 more times. Watch the status text. The cache size should max out at 5 entries and stay there.
  • Take a third heap snapshot.
  • Compare the retained size of strings in this third snapshot with the second snapshot. The retained size should be roughly flat (around 5 MB instead of growing to 10 MB), proving old entries were evicted.

Why does this fix work?

The shift() method removes the first element from the array. Once that reference is gone, the old string has no path back to a root. The garbage collector can now mark it as unreachable and reclaim its memory on the next sweep.

This pattern (capping a collection and evicting the oldest entry) is called a bounded cache. You will see it in production code anywhere data accumulates over time: logging buffers, analytics queues, and undo history stacks.

Retained size still growing after the fix?

Make sure you clicked Fix Leak before clicking Create Leak the additional 10 times. The fix must run first to flip arrayLeakMode to false. If the status text still says LEAKING (no limit), the fix did not run.

Help me figure out why my array fix isn't limiting the cache size.

You have now proven all three classic memory leak patterns and their fixes using heap snapshots. Next up, you will tackle a bonus challenge in the Secret Mission.

Secret mission

The Forgotten Timer Leak

You've conquered detached DOM nodes, runaway event listeners, and unbounded arrays. But there's a fourth leak pattern that haunts production apps: the forgotten timer. Can you detect it with the Allocation Timeline and stop it with clearInterval?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. All your files live on your machine and nothing is running in the background.

Resources you used:

  • Local project files: index.html, style.css, and leaks.js.
  • Google Chrome (used for DevTools Memory panel).

Keep everything running

No action needed. Choose this if you want to keep your Memory Leak Lab as a reference for interview prep or future debugging practice.

  • Your project files stay on your machine and take up minimal disk space.
  • You can reopen index.html in Chrome anytime to revisit the experiments.

Pause - I'll come back to this later

Close any open tabs and editors to free up memory. Your files will be waiting when you return.

  • Close the Chrome tab with your Memory Leak Lab page.
  • Close the project in VS Code.
  • Your files remain on disk. Reopen them anytime to continue.

Delete - I don't want to use this again

Remove all project files from your machine. You can always rebuild from scratch using this project guide.

  • Close the Chrome tab with your Memory Leak Lab page.
  • Close the project in VS Code.
  • Delete your project folder (the one containing index.html, style.css, and leaks.js) by running this command in your terminal:

macOS

rm -rf ~/Desktop/memory-leak-lab

Windows

rmdir /s /q %USERPROFILE%\Desktop\memory-leak-lab

If your folder has a different name, replace memory-leak-lab with whatever you named it.

Nice Work!

Nice Work!

Nice work! You just built a fully interactive Memory Leak Lab that demonstrates three classic JavaScript memory leak patterns, and you proved each one with real heap snapshot evidence from Chrome DevTools.

You've learned how to:

  • Built an interactive Memory Leak Lab that triggers and fixes three common JavaScript memory leak patterns: detached DOM nodes, forgotten event listeners, and unbounded array growth.
  • Explained how JavaScript's mark-and-sweep garbage collector determines what to keep vs. reclaim based on reachability from roots.
  • Used Chrome DevTools heap snapshots to detect detached DOM nodes, compare memory before and after actions, and prove that your fixes actually reclaimed memory.
  • Secret Mission: Detected a forgotten timer leak using the Allocation Timeline and fixed it with clearInterval.

Ready to quiz yourself?