How does JavaScript handle memory leaks and how can you avoid them?
Advertisement
🧩 Scenario
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
| Pattern | Leak Risk | Fix |
|---|---|---|
| Event listener in useEffect | High without cleanup | Return cleanup from useEffect; remove the listener |
| setInterval in component | High without cleanup | clearInterval in useEffect return |
| Module-level Map cache | High without eviction | Add max-size eviction or use WeakMap with object keys |
| DOM ref in closure | Medium | Set 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
WeakMapas the GC-safe alternative toMapfor per-object metadata
Follow-Up Questions
- How do you take two heap snapshots in Chrome DevTools and compare them to find retained objects?
- What is the "Allocation instrumentation on timeline" view and when would you use it over a snapshot?
- If a third-party library attaches events to
windowand never removes them, how would you detect the leak? - In a Node.js server that handles thousands of requests per hour, what are the most common leak sources?
- How does
FinalizationRegistryin modern JavaScript allow you to react when an object is collected?
Common Candidate Mistakes
- Not removing event listeners in React
useEffectcleanup, 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
setIntervalin 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
Mapwith 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
useEffectcleanup 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.
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