React Native Memory Leak Detection: A Practical Guide
Memory leaks are one of the hardest bugs to diagnose in React Native. Unlike a crash with a clear stack trace, a memory leak manifests gradually — the app gets slower over time, RAM usage climbs, and eventually the OS kills the process. By the time users report it, you have no stack trace and no obvious culprit.
This guide covers how to detect memory leaks in React Native, the most common patterns that cause them, and how to fix each one.
How Memory Leaks Manifest in React Native
React Native has two memory heaps to be aware of:
JavaScript heap — The V8/Hermes heap used for JS objects, React component state, closures, and arrays. Leaks here are detected by JavaScript profiling tools.
Native heap — Memory used by native modules, image bitmaps, and the Android/iOS runtime. Leaks here are detected by platform-level profilers (Android Profiler, Xcode Instruments).
A "memory leak" in React Native is almost always one of:
- A JS reference being held longer than necessary (event listener not cleaned up, closure capturing a component)
- A native module holding onto a reference after the JS side has unmounted
- Images being loaded into memory but never released from cache
Signs You Have a Memory Leak
- Increasing memory usage over time — RAM usage grows with each screen navigation and never returns to baseline after navigating back
- Performance degradation in long sessions — The app gets noticeably slower after 10–15 minutes of use
- OOM crashes on Android — The OS kills the app without a JavaScript stack trace (shows as process death in crash reporters)
- "Memory warning" breadcrumbs on iOS — iOS fires memory warnings before killing the process
Detection Method 1: Monitor Memory in Crash Reports
Add memory pressure monitoring to your crash reporter so you can correlate high memory usage with crash sessions:
import { AppState } from 'react-native';
import BugsPulse from '@bugspulse/react-native';
// Track when iOS sends memory warnings
AppState.addEventListener('memoryWarning', () => {
BugsPulse.addBreadcrumb({
message: 'iOS memory warning — heap pressure high',
level: 'warning',
});
});If you see sessions where multiple memory warnings appear in the timeline before an OOM crash, you have a memory leak.
Detection Method 2: Hermes Memory Profiler
If you're using Hermes (the default JS engine since RN 0.64), you can capture a heap snapshot during a profiling session:
1. Open Metro and run your app in dev mode
2. Open Chrome DevTools and connect via chrome://inspect
3. Navigate to the Memory tab → Take heap snapshot
4. Navigate through your app (especially the screens you suspect)
5. Take another heap snapshot
6. Compare: objects that grew significantly between snapshots are the leak candidates
Detection Method 3: Android Profiler
For native memory leaks, use Android Studio's Memory Profiler:
1. Run the app on a physical device via USB
2. Open Android Studio → View → Tool Windows → Profiler
3. Select your app process
4. Record memory for 2–5 minutes while navigating the app
5. Look for continuously increasing Heap + Native memory lines
Common React Native Memory Leak Patterns
1. Event listeners not removed in useEffect cleanup
The most common cause. An event listener is added when a component mounts but never removed when it unmounts.
// WRONG — listener accumulates on every mount
useEffect(() => {
const subscription = DeviceEventEmitter.addListener('event', handleEvent);
// Missing return cleanup!
}, []);
// CORRECT
useEffect(() => {
const subscription = DeviceEventEmitter.addListener('event', handleEvent);
return () => subscription.remove();
}, []);The same pattern applies to: Keyboard listeners, AppState listeners, NetInfo subscriptions, WebSocket connections, and timer intervals.
2. setInterval / setTimeout not cleared
// WRONG — interval runs forever even after unmount
useEffect(() => {
const id = setInterval(fetchData, 5000);
// Missing clearInterval!
}, []);
// CORRECT
useEffect(() => {
const id = setInterval(fetchData, 5000);
return () => clearInterval(id);
}, []);3. Async callbacks after unmount
An async operation completes and tries to update state on an unmounted component. While this doesn't always cause a leak, it keeps a reference to the component alive until the promise resolves.
// CORRECT — use a ref to track mount state
const isMounted = useRef(true);
useEffect(() => {
return () => { isMounted.current = false; };
}, []);
async function fetchData() {
const data = await api.getData();
if (isMounted.current) {
setData(data);
}
}4. Closures capturing large objects
A closure referencing a large array or object prevents it from being garbage collected, even after the component that created it unmounts.
// WRONG — the closure captures the entire largeDataset array
const handlePress = useCallback(() => {
const result = largeDataset.find(item => item.id === selectedId);
processResult(result);
}, [largeDataset, selectedId]); // largeDataset stays in memory
// BETTER — extract only what you need
const selectedItem = useMemo(
() => largeDataset.find(item => item.id === selectedId),
[largeDataset, selectedId]
);
const handlePress = useCallback(() => {
processResult(selectedItem);
}, [selectedItem]);5. FlatList not recycling properly
FlatList uses a recycling mechanism, but if you provide overly complex renderItem functions or forget keyExtractor, React can't optimize recycling, keeping thousands of component instances in memory.
// CORRECT — always provide a stable keyExtractor
<FlatList
data={items}
keyExtractor={(item) => item.id} // stable, unique key
renderItem={({ item }) => <ItemRow item={item} />}
removeClippedSubviews={true} // remove off-screen items from native hierarchy
maxToRenderPerBatch={10}
windowSize={5}
/>6. Large image assets not managed
Images in React Native are loaded into native memory. If you display many large images in a scroll view without managing cache, native memory grows unboundedly.
Use react-native-fast-image which supports proper cache control:
import FastImage from 'react-native-fast-image';
<FastImage
source={{ uri: imageUrl, priority: FastImage.priority.normal }}
resizeMode={FastImage.resizeMode.cover}
/>
// Clear cache when memory pressure is high
AppState.addEventListener('memoryWarning', () => {
FastImage.clearMemoryCache();
});Fixing Memory Leaks Systematically
1. Profile first — Don't guess. Use Hermes profiler or Android Profiler to confirm a leak and identify which objects are growing.
2. Navigate to suspect screens 10× in succession — Navigate to a screen and back 10 times. If memory grows with each cycle and doesn't return to baseline, that screen has a leak.
3. Check every useEffect for a cleanup return — This is the #1 source of leaks. Every subscription, timer, or async operation started in useEffect must be cancelled in the return function.
4. Search for `addListener` without `remove` — grep -r "addListener" src/ and verify every result has a corresponding cleanup.
5. Monitor with breadcrumbs — Add memory warning breadcrumbs to your crash reporter. If you see them in sessions that later crash, you have a leak in a commonly used flow.
Memory leaks don't announce themselves. The only way to stay ahead of them is to profile regularly, treat memory warnings as crash precursors, and enforce cleanup discipline in every component.