How to Detect and Fix Memory Leaks in JavaScript Applications

Advanced18 min interview
Skills tested:
Diagnosing steady heap growth using the Chrome Memory panel performance workflowIdentifying the four most common SPA memory leak patterns by reading heap snapshotsWriting fixes for each pattern: cleanup functions, null assignments, bounded cachesUnderstanding what "retained size" means vs "shallow size" in a heap snapshotApplying process.memoryUsage and node --inspect for Node.js leak detection

Advertisement

🧩 Scenario

In a real codebase, memory leaks in a SPA surface as pages that are responsive when first loaded but grow slower over 20 to 30 minutes of use. Navigation between routes does not reclaim memory from the previous route. Opening a dialog 10 times leaves 10x the memory consumed. Fixing the symptom requires identifying exactly which object is retained and exactly which reference is keeping it alive.

Architecture Walkthrough

Heap Snapshot Comparison Workflow

The two-snapshot workflow is the foundation of browser-based leak detection. Navigate to the Memory panel in Chrome DevTools, force a garbage collection (click the trash icon), and take snapshot 1 as the baseline. Perform the action suspected of leaking (navigate to a route, open a modal, submit a form). Force another GC collection and take snapshot 2. Switch to Comparison view.

The Comparison view shows the delta: how many objects of each type were added (+) and how many were freed (-) between the two snapshots. Objects with a positive delta that should have been freed (component instances, DOM nodes, large arrays) are the suspects. Click on a suspect type to see its instances and select any instance to view its retainer tree.

The retainer tree shows the path from a GC root to the retained object. Reading it from bottom (the suspect object) to top (the root) reveals the chain of references that prevents collection. The first link in the chain that should not exist is where the fix goes.

Shallow Size vs Retained Size

Shallow size is the memory consumed by the object itself, not counting anything it references. Retained size is the total memory that would be freed if the object were collected: its own shallow size plus the shallow sizes of all objects that are exclusively reachable through it.

For leak diagnosis, retained size is the number that matters. A tiny object (8 bytes shallow size) can have a retained size of 50MB if it holds a reference to a large array that nothing else points to. The snapshot summary sorts by retained size by default, which surfaces the biggest memory holders.

Node.js Leak Detection

For server-side leaks, the equivalent workflow uses node --inspect to enable the DevTools inspector connection. Load the Memory panel by navigating to chrome://inspect in Chrome, opening the inspector for the Node.js process, and then using the Memory panel identically to the browser workflow. For automated monitoring, process.memoryUsage().heapUsed gives the current heap size and can be logged to detect growth over time.


Key Code Explained

// Pattern A: Retained closure — fix by removing the reference
class ReportView {
  constructor(reportData) {
    this.data = reportData; // large array

    // Bug: closure captures 'this' (and thus this.data) forever
    document.addEventListener('keydown', this.handleKey);
  }

  handleKey = (e) => {
    if (e.key === 'Escape') this.close();
  };

  destroy() {
    document.removeEventListener('keydown', this.handleKey); // breaks the reference chain
    this.data = null; // allows GC of the large array even if 'this' is somehow still referenced
  }
}

// Pattern B: Detached DOM node — fix by nulling the reference
let cachedNode = null;

function cacheNode() {
  cachedNode = document.querySelector('.expensive-widget');
}

function removeWidget() {
  document.querySelector('.expensive-widget').remove();
  cachedNode = null; // REQUIRED — otherwise the detached node stays in memory
}

// Pattern C: Unbounded Map cache — fix with LRU eviction
const MAX_SIZE = 200;
const userCache = new Map();

function cacheUser(id, data) {
  if (userCache.size >= MAX_SIZE) {
    // Evict the oldest entry (Map preserves insertion order)
    const oldestKey = userCache.keys().next().value;
    userCache.delete(oldestKey);
  }
  userCache.set(id, data);
}

// Pattern D: Observable subscription not unsubscribed
function useRealTimeData(channel) {
  const [data, setData] = useState(null);

  useEffect(() => {
    const subscription = dataStream.subscribe(channel, (update) => {
      setData(update);
    });

    return () => {
      subscription.unsubscribe(); // REQUIRED: breaks the closure + subscription reference
    };
  }, [channel]);

  return data;
}

// Detecting growth programmatically for automated testing
async function measureLeakAfterAction(action) {
  const before = performance.memory?.usedJSHeapSize ?? 0;

  await action(); // render component, interact, unmount

  // Allow GC to run
  await new Promise((r) => setTimeout(r, 500));

  const after = performance.memory?.usedJSHeapSize ?? 0;
  const delta = after - before;

  if (delta > 1_000_000) { // more than 1MB retained after cleanup
    console.warn(`Potential leak: ${(delta / 1024 / 1024).toFixed(2)}MB retained`);
  }

  return delta;
}

The this.data = null in pattern A's destroy() is an extra safety measure. Even if some other code accidentally holds a reference to the ReportView instance, nulling this.data ensures the large array is no longer reachable through it. It decouples the lifecycle of the large data from the lifecycle of the view object.


Tradeoffs

Diagnostic toolBest forLimitation
Two heap snapshotsQuantifying and locating a specific action's leakDoes not show which code path allocates
Allocation timelineFinding which code path causes steady growthHigh overhead during recording
performance.memoryAutomated regression testing for leaksOnly available in Chrome; coarse resolution
node --inspectServer-side Node.js leak detectionRequires a debug build; not for production

What Interviewers Actually Check

  • Whether you can describe the two-snapshot workflow including the GC collection step before each snapshot
  • Whether you know the difference between shallow size and retained size
  • Whether you can read a retainer tree and interpret which reference is the leak source
  • Whether you can name and fix four common SPA leak patterns
  • Whether you know how to detect leaks in Node.js vs browser environments

Follow-Up Questions

  1. How would you set up a CI test that automatically fails if a specific component's mount/unmount cycle increases heap size beyond a threshold?
  2. What is the Detached Elements panel in Chrome DevTools (introduced in 2022) and how does it simplify finding detached DOM nodes?
  3. How does React DevTools Profiler relate to memory profiling, and what can it tell you that the Memory panel cannot?
  4. If your app uses a third-party analytics library that attaches global listeners, how would you confirm it is leaking?
  5. How does FinalizationRegistry help you observe when objects are collected, and how would you use it to verify a leak fix?

Common Candidate Mistakes

  • Describing the fix as "avoid closures" or "use less memory" without identifying which specific reference is the problem
  • Not knowing that retained size (not shallow size) determines a leak's real impact on memory
  • Thinking obj = null immediately frees the object when GC timing is non-deterministic and may take hundreds of milliseconds
  • Finding that event listeners are clean and concluding there is no leak, without checking subscriptions, timers, and module-level caches
  • Not knowing the steps to inspect a Node.js process using node --inspect and Chrome DevTools

Interview Readiness Checklist

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

  • Can you describe the browser-based two-snapshot comparison workflow step by step?
  • Can you explain the difference between shallow size and retained size in a heap snapshot?
  • Can you write code fixes for four common SPA leak patterns?
  • Can you describe how to use node --inspect with the Memory panel for server-side leak detection?
  • Can you use performance.memory to detect heap growth programmatically during automated testing?

Summary

Memory leaks in JavaScript SPAs manifest as heap size that grows over time and does not recover when the actions that caused the growth are reversed. The correct diagnostic workflow is to take two heap snapshots (with a forced GC before each), compare them in the Comparison view to find unexpected retained objects, and trace the retainer tree of each suspect to find the reference chain that prevents collection.

Retained size is the metric that matters: a small object with a 50MB retained size is leaking 50MB, not its own few bytes. The retainer tree reveals which application code is the anchor. Common anchors in SPAs are event listeners on document or window without cleanup, WebSocket or observable subscriptions without unsubscribe, module-level Map caches with no eviction, and JavaScript variables holding references to detached DOM nodes.

Each pattern has a straightforward fix: cleanup functions in useEffect return or class destroy methods, null assignments to detached node references, bounded caches with LRU eviction, and unsubscribe calls that break the subscription reference. Fixing the leak means breaking the specific reference chain the retainer tree shows, not generally "using less memory."

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

What causes most memory leaks in JS apps?

Unremoved event listeners, retained closures over large objects, or variables holding references to detached DOM nodes.

Advertisement


Stay Updated

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

Advertisement