Memory12 min read

Tracking Down Memory Leaks in Browser JavaScript: A Practical Field Guide

Memory leaks in the browser are subtle and hard to reproduce. This guide covers practical techniques to detect, isolate, and fix them using Chrome DevTools, heap snapshots, and allocation timelines.

memory leaksbrowser debuggingChrome DevToolsheap snapshotsdetached DOM trees

Memory leaks in the browser are notoriously hard to track down. They don't crash the tab immediately — they degrade performance over time, causing jank, stuttering, and eventually an OOM tab crash. You open DevTools, see memory growing, but can't figure out why.

I've spent days chasing such leaks in production apps. This post distills what actually works: a systematic approach using Chrome DevTools, heap snapshots, and a few non-obvious tricks.

Start With the Right Tools

You already have Chrome DevTools open. But are you using the right profiler? The Memory tab has three types: Heap snapshot, Allocation instrumentation on timeline, and Allocation sampling. Use them in this order:

  1. 1Take a heap snapshot before performing an action that might leak.
  2. 2Perform the action (e.g., open/close a modal, navigate to a route).
  3. 3Take a second heap snapshot.
  4. 4Switch to 'Comparison' view and filter for 'Objects allocated between snapshots 1 and 2'.
lightbulb

Don't rely on the 'Memory' tab's time-series graph alone. It shows total heap size, but that includes garbage that hasn't been collected yet. Force a GC with the trash can icon, then take a snapshot.

Finding Detached DOM Trees

Detached DOM trees are DOM nodes that were removed from the document but are still referenced by JavaScript. They're a common leak source. In the heap snapshot summary, filter by 'Detached'. You'll see nodes like 'Detached HTMLDivElement'.

A detached DOM tree because the modal node is still referenced in the cache Map.
// Example: accidentally retaining a detached DOM node
const cache = new Map();

function renderModal() {
  const modal = document.createElement('div');
  modal.innerHTML = '<p>Modal content</p>';
  document.body.appendChild(modal);
  
  // Later, we remove the modal but keep a reference
  cache.set('modal', modal);  // <-- leak!
  document.body.removeChild(modal);
}

The Real Culprit: Closures and Event Listeners

I once debugged a leak where a single closure retained an entire ViewModel. The code looked innocent enough:

The onClick closure holds a reference to viewModel, preventing GC even after the button is gone.
function setupButton(button, viewModel) {
  button.addEventListener('click', function onClick() {
    viewModel.handleClick();
  });
  // Button is later removed from DOM, but the listener keeps viewModel alive
}

The fix is simple: use `{ once: true }` if it's a one-shot click, or explicitly remove listeners when the button is removed. But in complex apps with dynamic views, tracking these is hard.

The Tab That Died After 15 Minutes

  1. 0:00User opens a dashboard with multiple chart widgets.
  2. 5:00Memory climbs to 200 MB; tab feels sluggish.
  3. 10:00Memory hits 500 MB; scrolling janks.
  4. 15:00Tab crashes with 'Out of memory'.

Lesson

Each chart widget was re-created every time the dashboard refreshed, but old instances were still referenced by a global pub/sub system. The fix: unsubscribe on widget destroy.

Using Allocation Timeline for Spiky Leaks

When a leak is triggered by a specific user action (like clicking a button), the allocation timeline is your friend. Start recording, perform the action, stop recording. Look for blue bars that don't drop — those are retained allocations.

info

Allocation timeline can be noisy. Limit your recording to a short, repeatable action. Use the 'Record allocation profile' option to capture stack traces for each allocation, then sort by 'Retained size'.

The 'Update' Trick for React Apps

React apps often leak because of improper cleanup in useEffect. A quick way to check: in the heap snapshot, search for 'FiberNode' or 'ReactComponent'. If you see old component instances after unmounting, you have a leak.

Without cleanup, the interval keeps the component's scope alive even after unmount.
// Leaky React component
function MyComponent() {
  useEffect(() => {
    const interval = setInterval(() => {
      console.log('tick');
    }, 1000);
    // Missing return cleanup! interval never cleared
  }, []);
  return <div>...</div>;
}

Automating Leak Detection

Manual debugging is fine for a one-time fix. But for CI, you want automation. Use Puppeteer to navigate your app, take heap snapshots, and compare object counts. Here's a minimal script:

Puppeteer script to take heap snapshots before and after an action. Note: run Chrome with --expose-gc flag.
const puppeteer = require('puppeteer');

async function detectLeaks(url) {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto(url);

  await page.evaluate(() => window.gc()); // expose GC with --js-flags='--expose-gc'
  const snapshot1 = await page.takeHeapSnapshot();

  // Perform actions that should not leak
  await page.click('.open-modal');
  await page.waitFor(500);
  await page.click('.close-modal');
  await page.waitFor(500);

  await page.evaluate(() => window.gc());
  const snapshot2 = await page.takeHeapSnapshot();

  // Compare snapshots... (parse JSON, check for leaked objects)
  // Fail if certain object counts grow
  console.log('Snapshots taken. Analyze manually or with a diff tool.');
  await browser.close();
}
40%

of production memory leaks in SPAs are caused by detached DOM trees or unremoved event listeners (source: Chrome Dev Summit survey)

Wrapping Up

Memory leaks are solvable. The key is to stop guessing and start measuring. Use heap snapshots to see what's actually retained, allocation timelines to find where it's allocated, and closures/event listeners as your prime suspects.

And for heaven's sake, remove your event listeners. Your users will thank you when their tab doesn't crash after lunch.

Frequently asked questions

How do I open heap snapshots in Chrome?

Open DevTools (F12), go to the Memory tab, select 'Heap snapshot', and click 'Take snapshot'. You can take multiple snapshots and select the 'Comparison' view to see what objects grew between snapshots.

What is a detached DOM tree and why does it leak?

A detached DOM tree is a set of DOM nodes removed from the document but still referenced by JavaScript (e.g., in a variable or closure). The browser cannot garbage-collect them, so they stay in memory. Use the 'Detached' filter in heap snapshots to find them.

Can service workers cause memory leaks?

Yes. Service workers can hold references to large objects (e.g., cached responses) or event listeners that prevent garbage collection. Use the Application tab to inspect Cache Storage and the Service Worker's global scope.

How do I automate memory leak detection?

Use Puppeteer to navigate through your app, take heap snapshots at key points, and compare object counts. Tools like 'puppeteer-heap-snapshot' or custom scripts can fail the build if certain object types grow beyond a threshold.