Memory leaks in the browser are notoriously hard to track down. They don't crash the tab immediately — they degrade performance over time, causing jank, stuttering, and eventually an OOM tab crash. You open DevTools, see memory growing, but can't figure out why.
I've spent days chasing such leaks in production apps. This post distills what actually works: a systematic approach using Chrome DevTools, heap snapshots, and a few non-obvious tricks.
Start With the Right Tools
You already have Chrome DevTools open. But are you using the right profiler? The Memory tab has three types: Heap snapshot, Allocation instrumentation on timeline, and Allocation sampling. Use them in this order:
- 1Take a heap snapshot before performing an action that might leak.
- 2Perform the action (e.g., open/close a modal, navigate to a route).
- 3Take a second heap snapshot.
- 4Switch to 'Comparison' view and filter for 'Objects allocated between snapshots 1 and 2'.
Don't rely on the 'Memory' tab's time-series graph alone. It shows total heap size, but that includes garbage that hasn't been collected yet. Force a GC with the trash can icon, then take a snapshot.
Finding Detached DOM Trees
Detached DOM trees are DOM nodes that were removed from the document but are still referenced by JavaScript. They're a common leak source. In the heap snapshot summary, filter by 'Detached'. You'll see nodes like 'Detached HTMLDivElement'.
// Example: accidentally retaining a detached DOM node
const cache = new Map();
function renderModal() {
const modal = document.createElement('div');
modal.innerHTML = '<p>Modal content</p>';
document.body.appendChild(modal);
// Later, we remove the modal but keep a reference
cache.set('modal', modal); // <-- leak!
document.body.removeChild(modal);
}The Real Culprit: Closures and Event Listeners
I once debugged a leak where a single closure retained an entire ViewModel. The code looked innocent enough:
function setupButton(button, viewModel) {
button.addEventListener('click', function onClick() {
viewModel.handleClick();
});
// Button is later removed from DOM, but the listener keeps viewModel alive
}The fix is simple: use `{ once: true }` if it's a one-shot click, or explicitly remove listeners when the button is removed. But in complex apps with dynamic views, tracking these is hard.
The Tab That Died After 15 Minutes
- 0:00User opens a dashboard with multiple chart widgets.
- 5:00Memory climbs to 200 MB; tab feels sluggish.
- 10:00Memory hits 500 MB; scrolling janks.
- 15:00Tab crashes with 'Out of memory'.
Lesson
Each chart widget was re-created every time the dashboard refreshed, but old instances were still referenced by a global pub/sub system. The fix: unsubscribe on widget destroy.
Using Allocation Timeline for Spiky Leaks
When a leak is triggered by a specific user action (like clicking a button), the allocation timeline is your friend. Start recording, perform the action, stop recording. Look for blue bars that don't drop — those are retained allocations.
Allocation timeline can be noisy. Limit your recording to a short, repeatable action. Use the 'Record allocation profile' option to capture stack traces for each allocation, then sort by 'Retained size'.
The 'Update' Trick for React Apps
React apps often leak because of improper cleanup in useEffect. A quick way to check: in the heap snapshot, search for 'FiberNode' or 'ReactComponent'. If you see old component instances after unmounting, you have a leak.
// Leaky React component
function MyComponent() {
useEffect(() => {
const interval = setInterval(() => {
console.log('tick');
}, 1000);
// Missing return cleanup! interval never cleared
}, []);
return <div>...</div>;
}Automating Leak Detection
Manual debugging is fine for a one-time fix. But for CI, you want automation. Use Puppeteer to navigate your app, take heap snapshots, and compare object counts. Here's a minimal script:
const puppeteer = require('puppeteer');
async function detectLeaks(url) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(url);
await page.evaluate(() => window.gc()); // expose GC with --js-flags='--expose-gc'
const snapshot1 = await page.takeHeapSnapshot();
// Perform actions that should not leak
await page.click('.open-modal');
await page.waitFor(500);
await page.click('.close-modal');
await page.waitFor(500);
await page.evaluate(() => window.gc());
const snapshot2 = await page.takeHeapSnapshot();
// Compare snapshots... (parse JSON, check for leaked objects)
// Fail if certain object counts grow
console.log('Snapshots taken. Analyze manually or with a diff tool.');
await browser.close();
}of production memory leaks in SPAs are caused by detached DOM trees or unremoved event listeners (source: Chrome Dev Summit survey)
Wrapping Up
Memory leaks are solvable. The key is to stop guessing and start measuring. Use heap snapshots to see what's actually retained, allocation timelines to find where it's allocated, and closures/event listeners as your prime suspects.
And for heaven's sake, remove your event listeners. Your users will thank you when their tab doesn't crash after lunch.
Frequently asked questions
How do I open heap snapshots in Chrome?
Open DevTools (F12), go to the Memory tab, select 'Heap snapshot', and click 'Take snapshot'. You can take multiple snapshots and select the 'Comparison' view to see what objects grew between snapshots.
What is a detached DOM tree and why does it leak?
A detached DOM tree is a set of DOM nodes removed from the document but still referenced by JavaScript (e.g., in a variable or closure). The browser cannot garbage-collect them, so they stay in memory. Use the 'Detached' filter in heap snapshots to find them.
Can service workers cause memory leaks?
Yes. Service workers can hold references to large objects (e.g., cached responses) or event listeners that prevent garbage collection. Use the Application tab to inspect Cache Storage and the Service Worker's global scope.
How do I automate memory leak detection?
Use Puppeteer to navigate through your app, take heap snapshots at key points, and compare object counts. Tools like 'puppeteer-heap-snapshot' or custom scripts can fail the build if certain object types grow beyond a threshold.