JavaScript Error Monitoring

Debugging JavaScript Memory Leaks

Memory leaks slow pages and crash tabs. Learn to find detached DOM nodes and lingering listeners with Chrome DevTools heap snapshots — and prevent them.

JavaScript memory leaks are among the hardest bugs to spot. Your app seems fine in development. Then a user leaves a tab open for an hour, the page consumes 500 MB of RAM, and their browser tab crashes. A memory leak happens when your application holds onto objects it no longer needs—detached DOM nodes, lingering event listeners, cached data that never gets cleaned up—and the garbage collector can't free that memory.

This guide walks you through finding memory leaks in production using Chrome DevTools heap snapshots, understanding the common culprits (event listeners, closures, circular references, detached DOM), and most importantly, the patterns that prevent them from happening in the first place. If you're already using error tracking to catch global JavaScript errors, memory leaks often show up as crashes you can correlate with this detective work.

What counts as a memory leak?

A memory leak isn't always dramatic. Sometimes it's a few kilobytes every time a user does something. After 1,000 actions, your app has leaked 1 MB. After 10,000, the browser is struggling. The sneakiest leaks are ones that happen during normal use—opening and closing modals, switching between pages, re-rendering components.

A true memory leak in JavaScript means your code is keeping a reference to an object that it no longer uses. The garbage collector can only free memory when no references to an object remain anywhere in the call stack or the heap. If your code accidentally holds a reference—in a global variable, an event listener that was never removed, a closure that captured a large object—that memory stays allocated forever.

The danger is compounded in single-page applications. In a traditional server-rendered site, a page refresh clears memory. In an SPA, the same process runs for hours, so even small leaks become serious. React, Vue, and Svelte applications are especially vulnerable because components stay in memory indefinitely unless you manually clean up subscriptions and listeners.

The usual suspects: where leaks hide

Most memory leaks fall into a handful of patterns.

Event listeners that are never removed. You attach a listener with addEventListener('click', handler) but forget to call removeEventListener when the component unmounts or the modal closes. Every listener you forget to remove keeps its closure alive, and closures often capture the entire component tree.

// Memory leak: listener never removed
function setupModal() {
  const modal = document.getElementById('modal');
  const closeBtn = modal.querySelector('button');
  
  const handleClose = () => modal.classList.add('hidden');
  closeBtn.addEventListener('click', handleClose);
  // Missing: closeBtn.removeEventListener('click', handleClose)
}

Detached DOM nodes held by references. You remove an element from the DOM with element.remove() but still hold a reference to it in a variable. Until that variable is garbage collected, the entire subtree it contains stays in memory.

const element = document.getElementById('item');
// Later: remove from DOM
element.remove();

// But 'element' is still in scope, so the DOM node and all its children
// stay in memory—the garbage collector can't free them.

Circular references between objects. Modern garbage collectors handle cycles fine now, but when combined with detached DOM, they can cause problems. An object holds a reference to a DOM node, and that node holds a reference back (perhaps through node.data = obj).

Global or module-level caches without eviction. A cache that grows without ever removing old entries will eventually consume all available memory.

const userCache = {}; // Global cache

function cacheUser(id, user) {
  userCache[id] = user; // Never deleted
}

Finding leaks: heap snapshots in Chrome DevTools

The most reliable way to spot a memory leak is to take a heap snapshot before and after a user action (open/close a modal, navigate to a page and back), then compare them.

Step 1: Open DevTools and the Memory tab
In Chrome, press F12, click the Memory tab.

Step 2: Take a baseline snapshot
Click "Take snapshot" and wait for it to complete. Label it before.

Step 3: Reproduce the suspected leak
Do the action many times—open and close a modal 10 times, navigate back and forth between pages 10 times. Make it obvious.

Step 4: Take a second snapshot
Click "Take snapshot" again. Label it after.

Step 5: Compare
Open the first snapshot, change the view from "Summary" to "Comparison", select the second snapshot from the "Comparison" dropdown. DevTools will highlight objects that exist in the second snapshot but not the first—your leaked objects.

Click on a leaked object to see its retaining path: the chain of references keeping it alive. This retaining path is your map to the leak. If you see an event listener holding a modal open after you closed it, you found the culprit.

When comparing snapshots, focus on high-count increases. If you see "Detached HTMLDivElement" × 50 in the after snapshot, you've got a real leak. A single extra object is noise.

Fixing detached DOM nodes

Once you've identified a detached node in the retaining path, trace it back. Usually it's an event listener or a reference hiding in a closure.

// Leaked: reference still held after removal
function setupMenu() {
  let menu = document.getElementById('menu');
  const button = document.getElementById('toggle');

  button.addEventListener('click', () => {
    menu.classList.toggle('open');
  });
  
  // menu reference is captured in the closure. If the component is destroyed
  // but the button still exists (or vice versa), menu stays alive.
}

The fix is to clean up when the component unmounts. In React, use a cleanup function in useEffect:

useEffect(() => {
  const handler = () => setOpen(prev => !prev);
  button.addEventListener('click', handler);

  return () => {
    button.removeEventListener('click', handler);
  };
}, []);

In vanilla JavaScript or jQuery, store references to your cleanup functions so you can call them later:

function setupMenu() {
  const menu = document.getElementById('menu');
  const button = document.getElementById('toggle');

  const handleClick = () => menu.classList.toggle('open');
  button.addEventListener('click', handleClick);

  // Return a cleanup function
  return () => {
    button.removeEventListener('click', handleClick);
  };
}

const cleanup = setupMenu();
// Later, when destroying:
cleanup();

Preventing memory leaks in production code

The best memory leak is the one you never write. Here are the patterns that work:

Always remove listeners when they're no longer needed. This is non-negotiable. Use frameworks that help (React's cleanup, Vue's @ directives, Svelte's reactive statements) or be fanatical about manual removal in vanilla JS.

Use AbortController for async operations. If a user navigates away, cancel pending requests. Pending requests hold references to the component they were started in.

const controller = new AbortController();

fetch('/api/data', { signal: controller.signal })
  .then(res => res.json())
  .catch(err => {
    if (err.name !== 'AbortError') console.error(err);
  });

// Later, when cleaning up:
controller.abort();

Keep closures lean. If a callback doesn't need the whole component, don't capture it. Extract just the fields you need:

// Bad: captures entire user object in closure
user.addEventListener('change', () => {
  console.log(user);
});

// Better: capture only what you need
const userId = user.id;
user.addEventListener('change', () => {
  console.log(userId);
});

Limit cache sizes or use WeakMap. If you're caching DOM nodes or large objects, either bound the cache or use WeakMap, which doesn't prevent garbage collection:

// WeakMap: the cache won't keep nodes alive
const nodeData = new WeakMap();
nodeData.set(domNode, { /* data */ });

// If domNode is removed and has no other references, it can be GC'd
// and automatically removed from the WeakMap.

Beware of jQuery and third-party plugins. jQuery often stores data on DOM nodes via $.data(). If a node is removed but jQuery's data map still references it, it leaks. When using plugins, read their cleanup docs.

Some third-party libraries attach event listeners globally or store references without cleanup functions. If you suspect a library is leaking, test it in isolation with heap snapshots, and consider raising an issue or switching libraries if it doesn't clean up.

Monitoring memory in production

Heap snapshots are a development tool. In production, you won't be able to take them. However, you can monitor memory usage via the Performance API.

Modern browsers expose performance.memory (non-standard but widely supported):

setInterval(() => {
  const memory = performance.memory;
  console.log(`Used: ${memory.usedJSHeapSize / 1048576 | 0} MB`);
}, 5000);

Better yet, if you're using an error tracker to capture frontend errors, instrument your app to send memory metrics alongside other performance data. A spike in memory usage often correlates with the start of a leak. When you correlate memory growth with user actions or specific code changes captured in breadcrumbs, you can pinpoint exactly what triggered the leak.

Consider also using the Intersection Observer API or Mutation Observer carefully—these can become sources of leaks themselves if not cleaned up. Always unobserve when done.

Modern frameworks like React, Vue, and Svelte handle a lot of cleanup automatically if you use them correctly. But custom event listeners, timers, and intervals are still your responsibility. Always think: "If this component unmounts, does this resource get cleaned up?"

Tying memory monitoring to error tracking

Memory leaks often manifest as crashes or errors—a user's tab runs out of memory and the browser kills the page, or your app throws an error when trying to allocate more memory. If you're monitoring JavaScript errors in production, you'll see these crashes. The missing piece is connecting the crash back to the memory leak.

By instrumenting memory metrics and sending them alongside your error events, you get the full picture: not just "the page crashed" but "the page crashed after memory grew 300 MB over 30 minutes." That context turns a mysterious crash into a lead you can follow. Many teams find it helpful to add a memory-spike alert rule so you're notified immediately if a release or code change causes memory to balloon.

Start tracking errors in minutes

Catch memory leaks and the errors they cause — track JavaScript errors and performance metrics alongside your heap usage with LightTrace.

JavaScript memory leaks are sneaky, but they're findable if you know where to look. Start with heap snapshots in development, fix the patterns we've covered, and monitor aggressively in production. The reward is a fast, stable app that doesn't degrade over time.

Fix your next production error faster

Point any Sentry SDK at LightTrace — free up to 5,000 events/month.