Sitemap

React Memory Leaks — 10 Patterns you must know

9 min readApr 5, 2026

--

I have spent good number of years analyzing and fixing on how this memory leaks make your system bad or say pathetic for effective resource utilization and great user experience.

Which eventually hampers your conversion rates and brand identity.

Press enter or click to view image in full size
React Memory Leaks — 10 Patterns you must know

Almost after a week its finally pollished. This guide covers 10 must know memory leak patterns in React applications and how to prevent them.

I wanted to include real world examples as well in this but the length of the article was increasing plus it was taking bit time to include those.

Let’s get straight in to it…

1. Event Listeners Without Cleanup

Example — Memory Leak

function Component() {
useEffect(() => {
window.addEventListener('resize', handleResize);
// Missing cleanup!
}, []);

return <div>Content</div>;
}

Example — Proper Cleanup

function Component() {
useEffect(() => {
window.addEventListener('resize', handleResize);

return () => {
window.removeEventListener('resize', handleResize);
};
}, []);

return <div>Content</div>;
}

Why it leaks: Event listener remains attached even after component unmounts, holding reference to the component and preventing garbage collection.

2. Timers (setTimeout/setInterval) Without Cleanup

Example — Memory Leak

function Component() {
const [count, setCount] = useState(0);

useEffect(() => {
setTimeout(() => {
setCount(c => c + 1); // Component might be unmounted!
}, 1000);
}, []);

return <div>{count}</div>;
}

Example — Proper Cleanup

function Component() {
const [count, setCount] = useState(0);

useEffect(() => {
const timerId = setTimeout(() => {
setCount(c => c + 1);
}, 1000);

return () => clearTimeout(timerId);
}, []);

return <div>{count}</div>;
}

Why it leaks: Timer continues running after unmount, attempting to update state on unmounted component, causing React warning and potential memory leak.

3. Subscriptions Without Cleanup

Example — Memory Leak

function Component() {
const [data, setData] = useState(null);

useEffect(() => {
const subscription = dataStream.subscribe(newData => {
setData(newData);
});
// Missing unsubscribe!
}, []);

return <div>{data}</div>;
}

Example — Proper Cleanup

function Component() {
const [data, setData] = useState(null);

useEffect(() => {
const subscription = dataStream.subscribe(newData => {
setData(newData);
});

return () => subscription.unsubscribe();
}, []);

return <div>{data}</div>;
}

Why it leaks: Subscription remains active, continuing to call setState on unmounted component.

4. WebSocket Connections Without Cleanup

Example — Memory Leak

function Component() {
const [messages, setMessages] = useState([]);

useEffect(() => {
const ws = new WebSocket('ws://localhost:8080');

ws.onmessage = (event) => {
setMessages(prev => [...prev, event.data]);
};
// Missing close!
}, []);

return <div>{messages.map(m => <p key={m}>{m}</p>)}</div>;
}

Example —Proper Cleanup

function Component() {
const [messages, setMessages] = useState([]);

useEffect(() => {
const ws = new WebSocket('ws://localhost:8080');

ws.onmessage = (event) => {
setMessages(prev => [...prev, event.data]);
};

return () => {
ws.close();
};
}, []);

return <div>{messages.map(m => <p key={m}>{m}</p>)}</div>;
}

Why it leaks: WebSocket connection stays open, continuing to receive and process messages even after component unmounts.

5. Async Operations Without Cleanup

Example — Memory Leak

function Component() {
const [data, setData] = useState(null);

useEffect(() => {
fetchData().then(result => {
setData(result); // Component might be unmounted!
});
}, []);

return <div>{data}</div>;
}

Example — Proper Cleanup with Abort

function Component() {
const [data, setData] = useState(null);

useEffect(() => {
const controller = new AbortController();

fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(result => {
setData(result);
})
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});

return () => controller.abort();
}, []);

return <div>{data}</div>;
}

ALTERNATIVE — Cleanup with Flag

function Component() {
const [data, setData] = useState(null);

useEffect(() => {
let isMounted = true;

fetchData().then(result => {
if (isMounted) {
setData(result);
}
});

return () => {
isMounted = false;
};
}, []);

return <div>{data}</div>;
}

Why it leaks: Promise resolves after unmount, trying to update state on unmounted component.

6. Closures Holding Stale References

Example — Memory Leak

function Component() {
const [count, setCount] = useState(0);

useEffect(() => {
setInterval(() => {
setCount(count + 1); // Stale closure! Always uses initial count
}, 1000);
}, []); // Empty deps - count never updates

return <div>{count}</div>;
}

Example — Functional Update

function Component() {
const [count, setCount] = useState(0);

useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // Functional update - always uses latest
}, 1000);

return () => clearInterval(id);
}, []);

return <div>{count}</div>;
}

Why it leaks: Closure captures old value, interval never cleared, multiple intervals accumulate.

7. DOM References Without Cleanup

Example— Memory Leak

function Component() {
const divRef = useRef(null);

useEffect(() => {
const element = document.getElementById('external-element');
divRef.current = element; // Holding reference
// No cleanup
}, []);

return <div>Content</div>;
}

Example — Clear References

function Component() {
const divRef = useRef(null);

useEffect(() => {
const element = document.getElementById('external-element');
divRef.current = element;

return () => {
divRef.current = null; // Clear reference
};
}, []);

return <div>Content</div>;
}

Why it leaks: Ref holds reference to potentially removed DOM node, preventing garbage collection.

8. Third-Party Libraries Without Cleanup

Example— Memory Leak

function Component() {
useEffect(() => {
const chart = new Chart('#chart', {
type: 'line',
data: chartData
});
// Missing destroy!
}, []);

return <div id="chart"></div>;
}

Example — Proper Cleanup

function Component() {
useEffect(() => {
const chart = new Chart('#chart', {
type: 'line',
data: chartData
});

return () => {
chart.destroy(); // Clean up library instance
};
}, []);

return <div id="chart"></div>;
}

Why it leaks: Library instances often hold references to DOM nodes and have internal event listeners.

9. Global State Without Cleanup

Example — Memory Leak

// Global event emitter
const emitter = new EventEmitter();
function Component() {
const [notifications, setNotifications] = useState([]);

useEffect(() => {
emitter.on('notification', (data) => {
setNotifications(prev => [...prev, data]);
});
// Missing off!
}, []);

return <div>{notifications.length} notifications</div>;
}

Example — Proper Cleanup

const emitter = new EventEmitter();
function Component() {
const [notifications, setNotifications] = useState([]);

useEffect(() => {
const handler = (data) => {
setNotifications(prev => [...prev, data]);
};

emitter.on('notification', handler);

return () => {
emitter.off('notification', handler);
};
}, []);

return <div>{notifications.length} notifications</div>;
}

Why it leaks: Event handler remains registered on global emitter even after component unmounts.

10. Circular References

Example — Memory Leak

function Parent() {
const [child, setChild] = useState(null);
const parentData = { name: 'parent', child };

useEffect(() => {
const childData = { name: 'child', parent: parentData };
setChild(childData); // Circular reference!
}, []);

return <div>{child?.name}</div>;
}

Example — Avoid Circular References

function Parent() {
const [childName, setChildName] = useState(null);

useEffect(() => {
const childData = { name: 'child' };
setChildName(childData.name); // Store only what you need
}, []);

return <div>{childName}</div>;
}

Why it leaks: Circular references can prevent garbage collection in some cases.

Detection Tools

1. React DevTools Profiler

The React DevTools Profiler collects timing information about each component’s render cycle to identify performance bottlenecks.

# Enable profiler in production
<React.StrictMode>
<App />
</React.StrictMode>

2. Chrome DevTools Memory Profiler

  1. Open DevTools > Memory tab
  2. Take heap snapshot
  3. Interact with app
  4. Take another snapshot
  5. Compare snapshots

Look for:

  • Detached DOM nodes
  • Increase in event listeners
  • Growing arrays/objects

3. Custom Memory Leak Detector

This custom hook utilizes the Cleanup Function of useEffect to monitor resources when a component leaves the DOM.

By logging the usedJSHeapSize upon unmounting, you can identify if memory usage fails to stabilize after multiple mount/unmount cycles, signaling potential leaks like uncleared intervals or event listeners.

function useMemoryLeakDetector(componentName) {
useEffect(() => {
console.log(`${componentName} mounted`);

return () => {
console.log(`${componentName} unmounted`);

// Check for leaks
if (window.performance.memory) {
console.log('Memory:', {
used: Math.round(window.performance.memory.usedJSHeapSize / 1048576),
total: Math.round(window.performance.memory.totalJSHeapSize / 1048576),
limit: Math.round(window.performance.memory.jsHeapSizeLimit / 1048576)
});
}
};
}, [componentName]);
}
// Usage
function MyComponent() {
useMemoryLeakDetector('MyComponent');
return <div>Content</div>;
}

Checklist for Every useEffect

Ask yourself:

  1. Does this effect add an event listener? → Add cleanup
  2. Does this effect start a timer? → Clear it
  3. Does this effect create a subscription? → Unsubscribe
  4. Does this effect open a connection? → Close it
  5. Does this effect start an async operation? → Cancel it
  6. Does this effect use a third-party library? → Destroy/cleanup instance
  7. Does this effect store a DOM reference? → Clear it
  8. Does this effect register with global state? → Unregister

Patterns & Best Practices

1. Custom Cleanup Hooks

This custom hook simplifies event management by encapsulating the Setup/Cleanup pattern.

function useEventListener(eventName, handler, element = window) {
useEffect(() => {
element.addEventListener(eventName, handler);

return () => {
element.removeEventListener(eventName, handler);
};
}, [eventName, handler, element]);
}
// Usage
function Component() {
useEventListener('resize', handleResize);
return <div>Content</div>;
}

2. AbortController Pattern

The AbortController Pattern solves the classic “race condition” in React. When a component unmounts or dependencies change before a network request finishes, the controller.abort() call signals the browser to cancel the fetch immediately.

function useAbortableEffect(effect, deps) {
useEffect(() => {
const controller = new AbortController();
effect(controller.signal);

return () => controller.abort();
}, deps);
}
// Usage
function Component() {
const [data, setData] = useState(null);

useAbortableEffect((signal) => {
fetch('/api/data', { signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
}, []);

return <div>{data}</div>;
}

3. Mounted Flag Pattern

The Mounted Flag Pattern uses a useRef to track whether a component is currently present in the DOM.

Since useRef doesn't trigger a re-render when its value changes, it is the perfect "silent" variable to keep track of a component's lifecycle state.

function useMounted() {
const mountedRef = useRef(false);

useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);

return mountedRef;
}
// Usage
function Component() {
const [data, setData] = useState(null);
const mounted = useMounted();

useEffect(() => {
fetchData().then(result => {
if (mounted.current) {
setData(result);
}
});
}, [mounted]);

return <div>{data}</div>;
}

4. Safe Async Pattern

The Safe Async Pattern is an evolution of the Mounted Flag. It wraps asynchronous logic in a reusable useCallback that checks the component's status before proceeding.

By intercepting both the result and the error, it ensures that your application logic only reacts if the component is still "alive" to receive the update.

function useSafeAsync() {
const mounted = useMounted();

const safeAsync = useCallback(async (asyncFn) => {
try {
const result = await asyncFn();
if (mounted.current) {
return result;
}
} catch (error) {
if (mounted.current) {
throw error;
}
}
}, [mounted]);

return safeAsync;
}

Testing for Memory Leaks

Unit Test Pattern

The Unit Test Pattern for cleanups ensures that your components aren’t leaving “garbage” behind in the browser environment.

import { render, unmount } from '@testing-library/react';
test('component cleans up on unmount', () => {
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');

const { unmount } = render(<Component />);
unmount();

expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
});

E2E Test Pattern

The E2E (End-to-End) Test Pattern moves beyond unit testing to verify how your application manages memory in a real browser environment.

By using tools like Puppeteer or Playwright, you can capture “Heap Snapshots” or memory metrics before and after a specific user flow (like opening and closing a heavy modal 10 times).

// Using Puppeteer
const metrics = await page.metrics();
const initialMemory = metrics.JSHeapUsedSize;
// Interact with app
await page.click('.open-modal');
await page.click('.close-modal');
const finalMetrics = await page.metrics();
const finalMemory = finalMetrics.JSHeapUsedSize;
// Memory should not grow significantly
expect(finalMemory - initialMemory).toBeLessThan(1000000); // 1MB

Advanced Topics

React 18+ Automatic Batching

Automatic Batching groups multiple state updates into a single re-render, even inside promises, timeouts, or native event handlers. Previously, React only batched updates within React event handlers.

In below example, calling setCount(1) and setFlag(true) triggers only one render instead of two. This optimization improves performance by reducing the computational overhead of "micro-renders."

// React 18 automatically batches, reducing unnecessary renders
function Component() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);

useEffect(() => {
// Both updates batched automatically
setCount(1);
setFlag(true);
}, []);

return <div>{count}</div>;
}

Strict Mode Double Invocation

This is a deliberate “stress test” designed to ensure your code is resilient to being mounted and destroyed repeatedly.

By running effects twice, React helps you catch:

  • Memory Leaks: Forgetting to unsubscribe from APIs or clear intervals.
  • Stale Closures: Dependencies that aren’t correctly tracked.
  • Impure Logic: Functions that produce side effects during the render phase.

Note: It doesn’t affect production builds

<React.StrictMode>
<App /> {/* Effects run twice in development */}
</React.StrictMode>

Golden Rules:

  1. Every effect that adds something must remove it
  2. Use cleanup functions religiously
  3. Test component unmounting
  4. Use React DevTools Profiler
  5. Monitor memory usage in production

Remember: Memory leaks compound over time. A small leak becomes a big problem with heavy usage.

Before You Go…

If this guide helped you level up, don’t let the learning stop here.

  • Stay ahead of the curve — Follow me on medium to receive the deep dives directly into your inbox for fresh, and practical insights.
  • Let’s connect on LinkedIn.

If you like the technical and insight full discussions/deep dives make sure to subscribe my YouTube channel for more amazing stuff.

--

--