How to Detect and Fix Memory Leaks in JavaScript Applications
Advertisement
🧩 Scenario
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 tool | Best for | Limitation |
|---|---|---|
| Two heap snapshots | Quantifying and locating a specific action's leak | Does not show which code path allocates |
| Allocation timeline | Finding which code path causes steady growth | High overhead during recording |
| performance.memory | Automated regression testing for leaks | Only available in Chrome; coarse resolution |
| node --inspect | Server-side Node.js leak detection | Requires 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
- 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?
- What is the Detached Elements panel in Chrome DevTools (introduced in 2022) and how does it simplify finding detached DOM nodes?
- How does React DevTools Profiler relate to memory profiling, and what can it tell you that the Memory panel cannot?
- If your app uses a third-party analytics library that attaches global listeners, how would you confirm it is leaking?
- How does
FinalizationRegistryhelp 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 = nullimmediately 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 --inspectand 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 --inspectwith the Memory panel for server-side leak detection? - Can you use
performance.memoryto 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."
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