How does JavaScript handle memory leaks and how can you avoid them?

Advanced16 min interview
Skills tested:
Identifying the four common memory leak patterns in JavaScriptHow closures over large objects create unintentional long-lived referencesWhy detached DOM nodes cause memory leaks even after removal from the documentHow to detect and profile memory leaks using Chrome DevToolsReact-specific memory leak patterns in useEffect and event listeners

Advertisement

🧩 Scenario

In a real codebase, memory leaks manifest as growing heap usage over time, slower performance on long-running sessions, and eventual browser tab crashes. The most common sources in frontend code are event listeners not removed on component unmount, timers that keep running after a component is gone, and module-level caches with no eviction. Identifying a leak requires heap snapshots and the DevTools Memory panel rather than code inspection alone.

Architecture Walkthrough

What Is a Memory Leak

A memory leak in JavaScript is memory that is allocated for an object but never freed because the garbage collector cannot reach it for collection. The GC frees objects that are unreachable from the root set. A leak means the application is keeping a reference alive that prevents the GC from treating the object as unreachable, even though the application logic no longer needs it.

Leaks do not always crash the application immediately. They accumulate over time: each user interaction or component mount adds a small amount of unfreeable memory. On long-running sessions, a tabbed dashboard, or a Node.js server that runs for days, this gradual growth causes slowdowns and eventual out-of-memory failures.

Forgotten Event Listeners

The most common leak pattern is attaching an event listener without ever removing it. The listener holds a closure reference. That closure typically captures variables from the scope where the listener was registered, including potentially large objects like DOM subtrees, API response data, or component state. As long as the listener exists on the target, the GC cannot collect anything the closure captures.

In React, the fix is always a cleanup function returned from useEffect. The cleanup runs before the next effect and on component unmount. Without it, every mount adds a new listener and no listener is ever removed.

Detached DOM Nodes and Global Caches

A detached DOM node is an element that has been removed from the document tree but is still referenced in JavaScript. The element is no longer visible or accessible to the user, but because JavaScript holds a reference, it cannot be collected. Its entire subtree stays in memory alongside it.

Module-level caches (plain Map or Array objects) are another common source. Any object added to a module-level cache lives until the cache entry is explicitly deleted or until the page is unloaded. If the cache has no eviction logic, it grows unboundedly. Using WeakMap with the object as the key is the GC-friendly alternative: when the key object has no other references, the GC can collect both the key and the associated value without an explicit delete.


Key Code Explained

// Pattern 1: Forgotten event listener in a React component
function SearchPanel() {
  const [query, setQuery] = useState('');

  useEffect(() => {
    function handleKeydown(e) {
      if (e.key === 'Escape') setQuery('');
    }
    document.addEventListener('keydown', handleKeydown);

    // Without this return, the listener is never removed on unmount
    return () => document.removeEventListener('keydown', handleKeydown);
  }, []);
}

// Pattern 2: setInterval not cleared on unmount
useEffect(() => {
  const id = setInterval(() => {
    fetchLatestData().then(setData);
  }, 5000);
  return () => clearInterval(id); // required — interval keeps running otherwise
}, []);

// Pattern 3: Detached DOM node retained by a variable
let cachedHeader = document.querySelector('.site-header');
document.querySelector('.site-header').remove();
// cachedHeader still holds the element — not collected until cachedHeader = null

// Pattern 4: Unbounded module-level cache
const responseCache = new Map();

async function fetchUser(id) {
  if (responseCache.has(id)) return responseCache.get(id);
  const data = await api.getUser(id);
  responseCache.set(id, data); // never evicted — grows indefinitely
  return data;
}

// Fix: WeakMap for per-object metadata (key collected when object is collected)
const nodeMetadata = new WeakMap();

function attachMetadata(node, data) {
  nodeMetadata.set(node, data);
  // When node is removed and dereferenced, GC collects both node and its metadata
}

The event listener pattern in React is the most practical to memorize. Every addEventListener inside a useEffect must have a corresponding removeEventListener in the cleanup function. Missing this means each component mount adds another listener for the lifetime of the page.


Tradeoffs

PatternLeak RiskFix
Event listener in useEffectHigh without cleanupReturn cleanup from useEffect; remove the listener
setInterval in componentHigh without cleanupclearInterval in useEffect return
Module-level Map cacheHigh without evictionAdd max-size eviction or use WeakMap with object keys
DOM ref in closureMediumSet reference to null when element is no longer needed

What Interviewers Actually Check

  • Whether you can name at least three concrete leak patterns, not just "holding references"
  • Whether you know the React-specific patterns (missing useEffect cleanup, uncleaned timers)
  • Whether you know what a detached DOM node is and why it stays in memory
  • Whether you can describe how to verify a leak using DevTools heap snapshots
  • Whether you know WeakMap as the GC-safe alternative to Map for per-object metadata

Follow-Up Questions

  1. How do you take two heap snapshots in Chrome DevTools and compare them to find retained objects?
  2. What is the "Allocation instrumentation on timeline" view and when would you use it over a snapshot?
  3. If a third-party library attaches events to window and never removes them, how would you detect the leak?
  4. In a Node.js server that handles thousands of requests per hour, what are the most common leak sources?
  5. How does FinalizationRegistry in modern JavaScript allow you to react when an object is collected?

Common Candidate Mistakes

  • Not removing event listeners in React useEffect cleanup, creating a new listener on every render cycle
  • Storing DOM references in module-level variables and never nulling them after the element is removed from the page
  • Using setInterval in a component without clearing it on unmount, causing the callback to run forever even after the component is gone
  • Accumulating data in a module-level Map with no eviction policy and not realizing it grows for the entire session
  • Saying "JavaScript handles this automatically with GC" without recognizing that GC cannot collect reachable objects regardless of whether they are still logically needed

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you name and describe four common memory leak patterns with a concrete example for each?
  • Can you explain why a forgotten event listener prevents GC of the closure it captures?
  • Can you explain what a detached DOM node is and how it stays in memory?
  • Can you describe how to use Chrome DevTools to find what is retaining an object?
  • Can you write a React useEffect cleanup that removes an event listener and clears a timer?

Summary

A JavaScript memory leak is memory that remains allocated because the GC considers the object reachable, even though the application no longer needs it. The GC correctly identifies and frees objects with no path from the root set, but it cannot free objects that are still referenced from a live scope, a listener, a closure, or a cache entry.

The four most common patterns are: forgotten event listeners (especially in React components without useEffect cleanup), uncleaned timers (setInterval running after component unmount), detached DOM nodes held in JavaScript variables, and unbounded module-level caches that accumulate entries without eviction. Each pattern has a straightforward fix: remove listeners in cleanup functions, clear intervals on unmount, null out DOM references after use, and use WeakMap or eviction logic for caches.

Detecting a leak in production requires heap snapshots rather than code inspection. Two snapshots taken before and after a suspected leak-causing action, compared in Chrome DevTools, reveal which objects were allocated between the snapshots and what is retaining them.

Related Videos
Watch these videos to learn more about this topic
Frequently Asked Questions

Can memory leaks happen in modern JS engines?

Yes. Even with garbage collection, unintentional references keep objects alive and prevent collection.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement