Suggest an editImprove this articleRefine the answer for “How to debug application and find memory leaks”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Memory leak** is when allocated memory stays alive because something holds a reference to it, blocking garbage collection. ```js // Leak: no cleanup, interval holds closure forever useEffect(() => { const id = setInterval(fetchData, 1000); }, []); // Fix: return cleanup function useEffect(() => { const id = setInterval(fetchData, 1000); return () => clearInterval(id); }, []); ``` **Key:** GC only frees unreachable objects. Timers, listeners, and closures keep objects reachable long past their useful life. To find leaks: take two heap snapshots in Chrome DevTools Memory tab before and after user actions, then diff them.Shown above the full answer for quick recall.Answer (EN)Image**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-prof` flag or `clinic.js`. React-specific: React Profiler combined with heap snapshots. ### Quick example ```tsx // 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 → `heapdump` module, trigger `heapdump.writeSnapshot()` on `SIGUSR2` - Catch leaks in CI → Puppeteer with `page.tracing` and 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:** ```tsx // 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:** ```tsx // 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:** ```js // 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:** ```tsx // 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:** ```js // 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 `useEffect` return - 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. ```tsx 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. ```tsx 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. ```js // 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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.