Fixing JavaScript Memory Leaks: Complete Guide

Memory leaks happen when objects stay in memory after they are no longer needed. Learn the most common leak patterns and how to find and fix them with DevTools.

7 min read

JavaScript memory leaks occur when objects remain in memory after they are no longer needed. JavaScript has automatic garbage collection, but it can only free objects that are unreachable.

A leak happens when references keep an object reachable even though the program will never use it again.

Memory leaks cause pages to use more and more memory over time. On desktop, this slows down the page. On mobile, it can crash the tab.

Leaks are especially dangerous in single-page applications that stay open for long sessions.

The Four Most Common Leak Patterns

Almost every JavaScript memory leak falls into one of four categories: forgotten timers, lingering event listeners, detached DOM nodes, and closures that capture too much.

Forgotten Timers and Intervals

setInterval runs a callback repeatedly until clearInterval is called. If the interval is never cleared, it keeps running and holds a reference to its closure scope forever.

javascriptjavascript
function startPolling() {
  const data = fetchLargeDataset();
 
  setInterval(() => {
    console.log(data.length);
  }, 1000);
}

The interval callback captures data through its closure. The interval never stops, so data is never eligible for garbage collection. Even if startPolling returns and its stack frame is gone, the interval keeps the data alive.

The fix is to clear the interval when it is no longer needed. In a component framework, clear it in the cleanup or unmount lifecycle. In vanilla JavaScript, track the interval ID and call clearInterval.

Lingering Event Listeners

Event listeners create references from the DOM element to the handler function. If the element is removed from the DOM but the listener reference survives, both the handler and its closure scope remain in memory.

javascriptjavascript
function setupHandler() {
  const heavyData = loadHeavyData();
  const button = document.getElementById("myButton");
 
  button.addEventListener("click", () => {
    processClick(heavyData);
  });
}

Even if the button is later removed from the DOM, the event listener holds a reference to the handler function, which holds a reference to heavyData through its closure. The data cannot be collected.

The fix is to remove event listeners when they are no longer needed. Use removeEventListener with the same function reference, or use the once option for one-shot handlers. Modern frameworks handle this automatically when components unmount.

Detached DOM Nodes

A detached DOM node is an element that has been removed from the document but is still referenced by JavaScript. The browser cannot garbage collect the node because a reference still exists.

javascriptjavascript
let cachedElement = null;
 
function cacheElement() {
  const element = document.getElementById("content");
  cachedElement = element;
  element.remove();
}

The element is removed from the DOM but cachedElement still references it. The node and its entire subtree stay in memory. Detached DOM nodes are a common source of leaks in single-page applications where views are swapped but references to old view elements persist.

The fix is to null out references to removed DOM elements. When a view is destroyed, set all references to its DOM nodes to null.

Closures That Capture Too Much

A closure retains the entire variable environment of its outer function, not just the variables it explicitly uses. If the outer function has a large data structure that the closure never reads, it is still kept alive.

javascriptjavascript
function createHandler() {
  const bigData = new Array(1000000).fill("data");
  const smallConfig = { option: true };
 
  return function handleClick() {
    console.log(smallConfig.option);
  };
}

The handleClick closure only uses smallConfig. But the JavaScript engine cannot know that ahead of time, so it keeps the entire variable environment alive. The million-element bigData array is retained for the lifetime of handleClick.

The fix is to scope variables tightly. If a large data structure is only needed temporarily, null it out after use. Better yet, structure the code so the closure only captures what it needs.

Finding Leaks with Chrome DevTools

The Memory panel provides three tools for finding leaks: heap snapshots, the allocation timeline, and the allocation sampling profiler.

Take a heap snapshot before an operation, perform the operation multiple times, and take another snapshot. Compare the two. Objects that appear in the second snapshot with a count proportional to the number of repetitions are likely leaking.

The retainer chain shows the path from a GC root to any object. Sort by retained size and investigate the largest objects. Click one and follow the retainer chain up to find what reference is keeping it alive.

For leaks that grow over time, use the allocation timeline. Record while performing the suspected leaking operation repeatedly. If the memory graph climbs without ever dropping, you have a leak.

Click the graph at the end to see what objects were allocated and never freed.

Preventing Leaks in Frameworks

React, Vue, and other component frameworks protect against many leak patterns automatically. Event listeners added in component lifecycle hooks are removed when the component unmounts. Timers set in effects are cleaned up.

But frameworks cannot protect against everything. Storing component references in a global store or module scope bypasses the framework's lifecycle management. Subscriptions to external data sources must be explicitly unsubscribed.

Always return a cleanup function from useEffect in React. Always use the onUnmounted hook in Vue. Treat every external subscription, timer, and event listener as a resource that must be released.

The Performance Panel Memory Checkbox

The Performance panel has a Memory checkbox that overlays the JavaScript heap size on the timeline during a recording. Enable it before recording an interaction.

The heap size graph should show a sawtooth pattern: memory climbs as the interaction runs, then drops back to baseline when the garbage collector runs. If the baseline keeps climbing after each interaction, memory is accumulating and you have a leak.

This is the fastest way to check for leaks. Record an interaction, repeat it several times, and watch the memory graph. If the baseline rises, investigate with heap snapshots.

For more on garbage collection mechanics, see /javascript/javascript-garbage-collection-complete-guide. For V8-specific GC details, read /javascript/how-v8-garbage-collector-works-in-javascript.

Rune AI

Rune AI

Key Insights

  • Forgotten setInterval calls keep running and hold references to their closure scope indefinitely.
  • Event listeners on removed DOM elements prevent garbage collection if references remain.
  • Detached DOM nodes are elements removed from the document but still referenced in JavaScript.
  • Closures that capture large data structures keep that data alive as long as the closure exists.
  • Use Chrome DevTools heap snapshots and the comparison view to find objects that should have been freed.
RunePowered by Rune AI

Frequently Asked Questions

How much memory is too much for a web page?

There is no fixed number, but a typical page should use under 50-100MB. If memory grows continuously without leveling off, or if a single interaction adds megabytes that never release, you likely have a leak.

Do memory leaks crash the browser?

Eventually, yes. A leaking page consumes more memory over time. On mobile devices with limited RAM, the browser may crash or the OS may kill the tab. The page becomes progressively slower before that point.

Conclusion

Memory leaks are not theoretical problems. They slow down pages, drain mobile batteries, and eventually crash tabs. The four most common causes are forgotten timers, lingering event listeners, detached DOM nodes, and closures that capture too much. Chrome DevTools heap snapshots and the allocation timeline make these leaks visible and fixable.