Description
Repeatedly mounting and unmounting a screen that renders many components (1000 Pressable-based rows inside a ScrollView) leaks memory that is never reclaimed, even after forcing GC and returning to a trivial screen.
Each Home → HeavyScreen → Home round-trip permanently retains a constant amount of JS heap and a much larger amount of native memory. Growth is linear and does not plateau, so a screen that is navigated into and out of repeatedly will grow process memory without bound.
The unmounted screen has no app-level retainers (no listeners, timers, subscriptions, refs, or module-level mutation; all callbacks are stable useCallbacks; rows are React.memo), so the retained tree should be fully collectible after unmount.
React Native version
react-native: 0.86.0-rc.3
react: 19.2.3
- Architecture: New Architecture (Fabric) + Bridgeless
- JS engine: Hermes
- Platform: Android (emulator, API 36, arm64-v8a)
- Build variant: debug
Reproduction repository
👉 https://github.com/fardad-dev/react-native-memory-leak — a fresh RN 0.86.0-rc.3
git clone https://github.com/fardad-dev/react-native-memory-leak
cd react-native-memory-leak
yarn
yarn android # New Architecture + Hermes, debug
Key files: App.tsx (manual useState navigation), src/screens/ScreenTwo.tsx (renders 1000 ComplexButtons in a ScrollView), src/components/ComplexButton.tsx (the Pressable-based row).
Steps to reproduce
- Run the repo above on an Android device/emulator (the app opens on a trivial Home screen).
- Tap Open Screen Two → to mount
ScreenTwo (1000 components), then ← Back to Home to fully unmount it.
- Repeat Home → Screen Two → Home ~10 times.
- After each return to Home, measure memory:
- Native (no debugger needed):
adb shell dumpsys meminfo com.awesomeprojectrc → TOTAL PSS / Native Heap.
- JS heap (optional): force GC and read Hermes live-heap-after-GC:
HermesInternal.getInstrumentedStats().js_allocatedBytes.
A condensed single-file version of the repro is also included at the bottom of this report.
Expected
After returning to the trivial Home screen and forcing GC, live memory returns to ~the first-visit baseline. The unmounted Heavy screen's component instances are collected.
Actual
Memory grows by a constant amount on every round-trip and never comes back down — even though measurements are taken on the identical empty Home screen, after 4× globalThis.gc().
Live JS heap (js_allocatedBytes, after forced GC, measured at Home):
| At Home after… |
Live JS heap (bytes) |
Δ vs previous |
| baseline |
1,653,368 |
— |
| cycle 1 |
2,602,032 |
+948,664 (incl. one-time init) |
| cycle 2 |
3,168,392 |
+566,360 |
| cycle 3 |
3,734,328 |
+565,936 |
| cycle 4 |
4,300,264 |
+565,936 |
| cycle 5 |
4,866,200 |
+565,936 |
| cycle 6 |
5,432,304 |
+566,104 |
| cycle 7 |
5,998,240 |
+565,936 |
| cycle 8 |
6,564,176 |
+565,936 |
| cycle 9 |
7,130,112 |
+565,936 |
| cycle 10 |
7,696,304 |
+566,192 |
→ ~565,936 bytes (~553 KB) retained per round-trip, constant to the byte across 9 cycles (~566 bytes × 1000 rows). js_externalBytes stays flat (1,116) and Hermes committed heap is only 32 MB, so this is not Hermes heap fragmentation.
Native memory (dumpsys meminfo, same empty Home screen, same PID):
|
Baseline Home |
After 10 cycles |
Δ |
| TOTAL PSS |
158,003 KB |
641,221 KB |
+472 MB |
| Native Heap |
64,428 KB |
461,736 KB |
+388 MB |
| Java Heap |
11,408 KB |
67,404 KB |
+55 MB |
| TOTAL RSS |
271,048 KB |
754,928 KB |
+484 MB |
→ ~47 MB total PSS leaked per round-trip (~39 MB of it native heap). The JS retention drags a much larger Fabric/native footprint (shadow nodes / host views) with it.
What was ruled out
- Not uncollected garbage:
globalThis.gc() is called 4× before each reading (js_numGCs climbed 0 → 1241 over the run); these are live, post-collection bytes.
- Not measurement screen state: every measurement is on the identical, fully-unmounted Home screen.
- Not app-level retention: the repro has no listeners/timers/subscriptions/global refs;
onPress/renderItem/keyExtractor are stable useCallbacks; rows are React.memo.
Condensed single-file reproduction
Self-contained App.tsx (same behavior as the linked repo)
/**
* Minimal reproduction: memory not reclaimed after unmounting a screen
* with many components, across repeated navigations (New Architecture / Fabric).
*
* Manual navigation: a single piece of state decides which screen is mounted,
* so going "back" fully unmounts the heavy screen.
*/
import React, { Fragment } from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
Text,
View,
type GestureResponderEvent,
} from 'react-native';
const ITEM_COUNT = 1000;
type Item = { id: string; index: number; label: string };
const DATA: Item[] = Array.from({ length: ITEM_COUNT }, (_, index) => ({
id: `two-${index}`,
index,
label: `Screen Two Item ${index}`,
}));
/** A deliberately multi-layered row so 1000 of them is a real amount of tree. */
function ComplexButtonBase({
index,
label,
onPress,
}: {
index: number;
label: string;
onPress: (index: number) => void;
}) {
const handlePress = React.useCallback(
(_e: GestureResponderEvent) => onPress(index),
[index, onPress],
);
const hue = (index * 37) % 360;
const progress = `${(index * 13) % 100}%` as const;
const initials = label.split(' ').map(p => p[0]).join('').slice(0, 2).toUpperCase();
return (
<Pressable onPress={handlePress} style={({ pressed }) => [styles.button, pressed && styles.pressed]}>
<View style={[styles.badge, { backgroundColor: `hsl(${hue}, 70%, 55%)` }]}>
<Text style={styles.badgeText}>{initials}</Text>
</View>
<View style={styles.body}>
<Text style={styles.title} numberOfLines={1}>{label}</Text>
<Text style={styles.subtitle} numberOfLines={1}>Tap to interact with item #{index}</Text>
<View style={styles.track}><View style={[styles.fill, { width: progress }]} /></View>
</View>
<View style={styles.meta}><Text style={styles.metaValue}>#{index}</Text></View>
</Pressable>
);
}
const ComplexButton = React.memo(ComplexButtonBase);
function HeavyScreen({ onBack }: { onBack: () => void }) {
const onPress = React.useCallback((i: number) => {}, []);
return (
<View style={styles.flex}>
<Text style={styles.link} onPress={onBack}>← Back to Home</Text>
<ScrollView>
{DATA.map(item => (
<Fragment key={item.id}>
<ComplexButton index={item.index} label={item.label} onPress={onPress} />
</Fragment>
))}
</ScrollView>
</View>
);
}
function HomeScreen({ onOpen }: { onOpen: () => void }) {
return (
<View style={[styles.flex, styles.center]}>
<Text style={styles.h1}>Home</Text>
{/* Optional in-app measurement: works when the runtime exposes globalThis.gc */}
<Text style={styles.link} onPress={measureLiveHeap}>Measure live JS heap (logcat)</Text>
<Text style={styles.link} onPress={onOpen}>Open Heavy Screen →</Text>
</View>
);
}
/** Forces GC (if available) and logs Hermes live-heap-after-GC. */
function measureLiveHeap() {
const g: any = globalThis as any;
for (let k = 0; k < 4 && typeof g.gc === 'function'; k++) g.gc();
const s = g.HermesInternal?.getInstrumentedStats?.();
console.log('[leak] js_allocatedBytes =', s?.js_allocatedBytes, ' js_heapSize =', s?.js_heapSize);
}
export default function App() {
const [heavy, setHeavy] = React.useState(false);
return (
<View style={[styles.flex, styles.top]}>
{heavy ? (
<HeavyScreen onBack={() => setHeavy(false)} />
) : (
<HomeScreen onOpen={() => setHeavy(true)} />
)}
</View>
);
}
const styles = StyleSheet.create({
flex: { flex: 1 },
top: { paddingTop: 50 },
center: { alignItems: 'center', justifyContent: 'center' },
h1: { fontSize: 28, fontWeight: '700', marginBottom: 24 },
link: { fontSize: 17, fontWeight: '600', color: '#4c8bf5', paddingVertical: 12 },
button: {
flexDirection: 'row', alignItems: 'center', padding: 12, marginHorizontal: 12,
marginVertical: 6, backgroundColor: '#fff', borderRadius: 14, borderWidth: 1, borderColor: '#e2e2e8',
},
pressed: { backgroundColor: '#f1f1f6' },
badge: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', marginRight: 12 },
badgeText: { color: '#fff', fontSize: 16, fontWeight: '700' },
body: { flex: 1 },
title: { fontSize: 15, fontWeight: '600', color: '#1c1c1e' },
subtitle: { fontSize: 12, color: '#8e8e93', marginTop: 2 },
track: { height: 4, borderRadius: 2, backgroundColor: '#ececf1', marginTop: 8, overflow: 'hidden' },
fill: { height: 4, borderRadius: 2, backgroundColor: '#4c8bf5' },
meta: { alignItems: 'flex-end', marginLeft: 12 },
metaValue: { fontSize: 13, fontWeight: '600', color: '#3a3a3c' },
});
Environment note: the JS-heap numbers above were captured with globalThis.gc() available in the local build; the native dumpsys meminfo evidence does not depend on gc(), so the leak is observable without it.
Description
Repeatedly mounting and unmounting a screen that renders many components (1000
Pressable-based rows inside aScrollView) leaks memory that is never reclaimed, even after forcing GC and returning to a trivial screen.Each Home → HeavyScreen → Home round-trip permanently retains a constant amount of JS heap and a much larger amount of native memory. Growth is linear and does not plateau, so a screen that is navigated into and out of repeatedly will grow process memory without bound.
The unmounted screen has no app-level retainers (no listeners, timers, subscriptions, refs, or module-level mutation; all callbacks are stable
useCallbacks; rows areReact.memo), so the retained tree should be fully collectible after unmount.React Native version
Reproduction repository
👉 https://github.com/fardad-dev/react-native-memory-leak — a fresh RN
0.86.0-rc.3Key files:
App.tsx(manualuseStatenavigation),src/screens/ScreenTwo.tsx(renders 1000ComplexButtons in aScrollView),src/components/ComplexButton.tsx(thePressable-based row).Steps to reproduce
ScreenTwo(1000 components), then ← Back to Home to fully unmount it.adb shell dumpsys meminfo com.awesomeprojectrc→TOTAL PSS/Native Heap.HermesInternal.getInstrumentedStats().js_allocatedBytes.A condensed single-file version of the repro is also included at the bottom of this report.
Expected
After returning to the trivial Home screen and forcing GC, live memory returns to ~the first-visit baseline. The unmounted Heavy screen's component instances are collected.
Actual
Memory grows by a constant amount on every round-trip and never comes back down — even though measurements are taken on the identical empty Home screen, after 4×
globalThis.gc().Live JS heap (
js_allocatedBytes, after forced GC, measured at Home):→ ~565,936 bytes (~553 KB) retained per round-trip, constant to the byte across 9 cycles (~566 bytes × 1000 rows).
js_externalBytesstays flat (1,116) and Hermes committed heap is only 32 MB, so this is not Hermes heap fragmentation.Native memory (
dumpsys meminfo, same empty Home screen, same PID):→ ~47 MB total PSS leaked per round-trip (~39 MB of it native heap). The JS retention drags a much larger Fabric/native footprint (shadow nodes / host views) with it.
What was ruled out
globalThis.gc()is called 4× before each reading (js_numGCsclimbed 0 → 1241 over the run); these are live, post-collection bytes.onPress/renderItem/keyExtractorare stableuseCallbacks; rows areReact.memo.Condensed single-file reproduction
Self-contained App.tsx (same behavior as the linked repo)