Skip to content

Memory not reclaimed after unmounting a screen with many components, across repeated navigations (New Architecture / Fabric, 0.86.0-rc.3) #57198

@fardad-dev

Description

@fardad-dev

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

  1. Run the repo above on an Android device/emulator (the app opens on a trivial Home screen).
  2. Tap Open Screen Two → to mount ScreenTwo (1000 components), then ← Back to Home to fully unmount it.
  3. Repeat Home → Screen Two → Home ~10 times.
  4. After each return to Home, measure memory:
    • Native (no debugger needed): adb shell dumpsys meminfo com.awesomeprojectrcTOTAL 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions