How to debug application and find memory leaks
Memory leak is when allocated memory stays alive because something holds a reference to it, so the garbage collector never gets to free it.
Theory
TL;DR
- Memory works like restaurant tables: reserve one, eat, then the table gets cleared. A leak is dirty plates piling up because nobody buses them, until the room is full.
- Normal JS frees memory automatically via garbage collection. Leaks happen when closures, timers, or event listeners keep objects reachable past their useful life.
- If memory climbs steadily while users navigate (watch Task Manager or Performance Monitor), profile before assuming anything else.
- Browser leaks: Chrome DevTools Memory tab. Node.js leaks:
--heap-profflag orclinic.js. React-specific: React Profiler combined with heap snapshots.
Quick example
// BAD: interval keeps running after component unmounts
function LeakyTimer() {
useEffect(() => {
const id = setInterval(() => console.log('tick'), 1000);
// No return = no cleanup = interval fires forever
// Closure retains component scope, heap grows indefinitely
}, []);
return <div>Timer</div>;
}
// GOOD: return a cleanup function
function CleanTimer() {
useEffect(() => {
const id = setInterval(() => console.log('tick'), 1000);
return () => clearInterval(id); // GC can now collect
}, []);
return <div>Timer</div>;
}After LeakyTimer unmounts, the interval keeps firing. The closure retains the component's scope, so V8 marks that memory as reachable and never frees it. The fix is one line.
Why GC doesn't save you
V8 uses mark-and-sweep: it starts from roots (globals, stack frames, active handles), marks everything reachable, then sweeps the rest. Leaks happen when your code accidentally creates root-like references that outlive their purpose.
A setInterval callback sits in the timer queue. That queue is a root. If your callback closes over a 10MB array, that array stays reachable for as long as the interval lives. Stack variables are different: they vanish the moment a function returns. But closures, event listeners, and global state live on the heap and stay until something explicitly breaks the reference.
When to reach for which tool
- Memory climbs during navigation in the browser → Chrome DevTools Memory tab, heap snapshots before and after user actions
- Node.js server slowly runs out of memory →
node --heap-prof server.js, open the output in DevTools - React component causes repeated memory growth → React Profiler flamegraph combined with the Memory tab
- Production Node crash with OOM →
heapdumpmodule, triggerheapdump.writeSnapshot()onSIGUSR2 - Catch leaks in CI → Puppeteer with
page.tracingand heap comparison between runs
Tool comparison
| Tool | Best for | How to use | Limitation |
|---|---|---|---|
| Chrome DevTools Memory | Browser JS, React | Take snapshot, interact, take second snapshot, diff in Summary view | Needs --inspect for Node |
node --heap-prof | Server-side Node | node --heap-prof server.js, open .heapprofile in DevTools | Verbose output, slows the process |
| React Profiler | Component re-renders | Record mount/unmount, check flamegraph for retained nodes | Misses timers and non-React code |
clinic.js / heapdump | Production Node | clinic heapusage -- node server.js | Requires install, not zero-config |
| Performance Monitor | Quick sanity check | DevTools → Performance Monitor → JS Heap Size in real time | No detail, only confirms a leak exists |
How V8 handles this internally
V8 runs two kinds of GC. Scavenge (minor GC) cleans the young generation fast, collecting short-lived objects. Mark-Compact (major GC) handles the old generation, but only runs under memory pressure. Objects that survive Scavenge get promoted to the old generation.
Leaks live in the old generation. A timer callback that closes over a large object keeps that object promoted and tenured. Every time Mark-Compact runs, it finds the object reachable and skips it. Over thousands of requests or navigation events, these objects accumulate until OOM kills the process.
Detached DOM nodes are a common browser variant. You remove an element from the DOM, but a JS variable or event listener still holds a reference. The element is detached from the page but alive in the heap. Chrome DevTools shows these clearly in the Summary view as "Detached HTMLDivElement" or similar.
Common mistakes
Forgetting to clear setInterval in useEffect:
// Wrong: interval runs until page reload
useEffect(() => {
const id = setInterval(fetchData, 5000);
}, []);
// Right: cleanup clears it on unmount
useEffect(() => {
const id = setInterval(fetchData, 5000);
return () => clearInterval(id);
}, []);fetch without AbortController on unmount:
// Wrong: pending promise retains the state updater closure
useEffect(() => {
fetch('/api/data').then(r => r.json()).then(setData);
}, []);
// Right: abort cancels the in-flight request
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(() => {}); // ignore abort errors
return () => controller.abort();
}, []);Using Map instead of WeakMap for caches:
// Wrong: strong reference, key object never collected
const cache = new Map();
cache.set(userObject, computedData);
// Right: key is held weakly, GC collects it when no other refs exist
const cache = new WeakMap();
cache.set(userObject, computedData);addEventListener without removeEventListener:
// Wrong: listener stays attached forever
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []);
// Right
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);Express handler that retains large data per request:
// Wrong: closure keeps 10MB buffer alive indefinitely per request
app.get('/data', async (req, res) => {
const largeData = await fetchBigPayload(req.query.id);
const watcher = setInterval(() => {
console.log(largeData.length); // holds largeData in closure
}, 5000);
res.json({ ok: true });
// interval never cleared, largeData never freed
});I've seen this exact pattern take down a Node API at around 500 concurrent users: each request added roughly 10MB to the heap, and none of it ever came back.
Ignoring detached DOM nodes in heap snapshots:
When you see "Detached HTMLDivElement" in a snapshot, that's a DOM node removed from the page but still referenced in JS. Check for listeners that weren't removed and React ref objects pointing at old nodes.
Real-world usage
- React components with WebSocket connections (chat widgets, live dashboards) → close socket in
useEffectreturn - Next.js SSR pages with subscriptions → cleanup pattern inside
getServerSideProps - Puppeteer screenshot services → periodic
page.close()to free detached page memory - Redux stores → avoid storing DOM nodes or large ArrayBuffers in state
- Node/Express APIs → factory functions per request instead of module-level singletons
Follow-up questions
Q: Walk me through debugging a React app that slows down after 10 minutes of navigation.
A: First, open Task Manager or Performance Monitor and confirm memory is actually climbing. Then go to DevTools Memory tab, take a heap snapshot, navigate for a few minutes, take another. Diff them in the Summary view and look for growing closures or detached DOM nodes. Then switch to the Performance tab and record a session to catch interval callbacks in the flame chart.
Q: What is the difference between a heap snapshot and an allocation timeline?
A: A snapshot is a point-in-time photo of all objects in the heap. An allocation timeline records every allocation over a period of time, which helps catch intermittent leaks that don't show up in a single snapshot comparison.
Q: How do you debug a Node.js memory leak in production without restarting the server?
A: Start Node with --inspect, then connect Chrome DevTools remotely. Or install heapdump, listen for SIGUSR2, and call heapdump.writeSnapshot() on that signal. Analyze the .heapsnapshot file in DevTools afterward.
Q: Why do WeakMaps help prevent memory leaks?
A: In a regular Map, the key is a strong reference. If you cache data by user object, that object lives as long as the Map does. In a WeakMap, the key is held weakly: once no other code references the object, GC can collect it and the WeakMap entry disappears automatically.
Q: React StrictMode mounts components twice. How does that help find leaks?
A: StrictMode runs mount, cleanup, and mount again. If your useEffect creates a timer or listener but the cleanup doesn't remove it, you'll have two timers after the second mount. Take a heap snapshot after the second mount and look for doubled allocations.
Q: In V8, why do leaks in the old generation cause more damage than in the young generation?
A: The young generation is cleaned by Scavenge, which runs frequently and fast. Objects that survive Scavenge get promoted to the old generation, where only full Mark-Compact GC can touch them. Leaked objects stay there indefinitely, growing the heap until OOM or a page reload clears everything.
Examples
Basic: setInterval leak in a React component
The most common version: a polling interval that keeps firing after the component is gone.
function PriceTracker({ symbol }: { symbol: string }) {
const [price, setPrice] = useState<number>(0);
useEffect(() => {
// Poll price every 3 seconds
const id = setInterval(async () => {
const res = await fetch(`/api/price?symbol=${symbol}`);
const data = await res.json();
setPrice(data.price); // warning after unmount: update on dead component
}, 3000);
return () => clearInterval(id); // this one line prevents the leak
}, [symbol]);
return <div>{symbol}: ${price}</div>;
}Without the return, id keeps running after PriceTracker unmounts. The closure captures symbol and setPrice, so the component's scope never gets collected. With the cleanup, GC sees no remaining references and frees everything on the next cycle.
Intermediate: search with debounce and abort
A realistic admin dashboard scenario: user types in a search box, you debounce and fetch. Two things need cleanup: the timer and the in-flight request.
function UserSearch({ users }: { users: User[] }) {
const [results, setResults] = useState<User[]>([]);
useEffect(() => {
const controller = new AbortController();
// Wait 300ms before sending the request
const timer = setTimeout(async () => {
try {
const res = await fetch('/api/users/search', {
signal: controller.signal,
});
const data = await res.json();
setResults(data);
} catch {
// AbortError is expected on cleanup
}
}, 300);
return () => {
clearTimeout(timer); // cancel pending debounce
controller.abort(); // cancel in-flight request
};
}, [users]);
return <ul>{results.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}Without the cleanup, every keystroke schedules a timer that captures the full users array. Fast typing means many closures, and the heap keeps climbing.
Advanced: Express handler with a timer that leaks per request
This one trips senior developers. It looks harmless until memory graphs at 3am tell a different story.
// BAD: retains large payload per request, forever
app.get('/data', async (req, res) => {
const largeData = await fetchBigPayload(req.query.id); // ~10MB buffer
const watcher = setInterval(() => {
// Closure captures largeData. Timer never cleared.
console.log('Payload size:', largeData.length);
}, 5000);
res.json({ ok: true });
// Response sent. largeData stays alive in the timer closure.
// 100 requests = ~1GB leaked heap
});
// GOOD: clear the interval when the response finishes
app.get('/data', async (req, res) => {
const largeData = await fetchBigPayload(req.query.id);
let watcher: ReturnType<typeof setInterval>;
res.on('finish', () => clearInterval(watcher)); // fires after response completes
watcher = setInterval(() => {
console.log('Payload size:', largeData.length);
}, 5000);
res.json({ ok: true });
});To catch this in production: run node --heap-prof server.js, hit it with a load test, then open the .heapprofile in Chrome DevTools. Look for fetchBigPayload in the retainer chain and trace up to the interval callback.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.