Memory and Leaks
Listeners and timers that outlive a screen, images that are larger than they look, and how to confirm a leak.
useEffect(() => { const sub = AppState.addEventListener('change', fn); return () => sub.remove(); }, [])A leak here is memory that is still reachable but no longer wanted. Two kinds matter. In JavaScript, a timer, listener or subscription keeps its callback alive, the callback closes over your component, and the component keeps its state and props alive. In native memory, a decoded image holds a bitmap. The symptom is the same: memory climbs each time the user enters and leaves a screen, and eventually Android kills the process or iOS terminates it.
Clean up what you start
Anything started in an effect is undone in the function that effect returns. setInterval pairs with clearInterval, a React Native event subscription with subscription.remove(), a fetch with an AbortController. One trap is specific to navigation: pushing a screen on top of another does not unmount the one underneath, so its timers keep firing. Gate that work on focus.
Images are bigger than their files
A 4000 by 3000 photo decodes to roughly 48MB in memory whatever the JPEG weighs, because it becomes four bytes per pixel. Request a sized image from the server, or let expo-image downscale it: allowDownscaling defaults to true, cachePolicy defaults to 'disk', and recyclingKey makes a recycled row drop the previous bitmap instead of showing it.
Confirming it
Take a heap snapshot in the Memory panel of React Native DevTools, push and pop the suspect screen several times, take a second snapshot, and compare retained sizes. Growing counts of your own component or closure names are the evidence.
Example
import { useEffect, useRef, useState } from "react";
import { AppState, StyleSheet, Text, View } from "react-native";
type SessionTimerProps = { label: string };
export default function SessionTimer({ label }: SessionTimerProps) {
const [seconds, setSeconds] = useState(0);
const foreground = useRef(true);
useEffect(() => {
const id = setInterval(() => {
if (foreground.current) {
setSeconds((s) => s + 1);
}
}, 1000);
// AppState.addEventListener returns a subscription object; remove()
// detaches it. There is no removeEventListener to call instead.
const subscription = AppState.addEventListener("change", (state) => {
foreground.current = state === "active";
});
// This cleanup runs on unmount. Without it, the interval and the listener
// both keep this component's closure - and its state - alive for the
// lifetime of the app.
return () => {
clearInterval(id);
subscription.remove();
};
}, []);
return (
<View style={styles.container}>
<Text style={styles.label}>{label}</Text>
<Text style={styles.value}>{`${seconds}s`}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 16, gap: 4 },
label: { color: "#6b7280" },
value: { fontSize: 24, fontVariant: ["tabular-nums"] },
});When to use it
- Memory grows by a few megabytes every time a detail screen is opened and closed.
- A list keeps polling the API after the user has navigated away.
- An image-heavy feed crashes on an older Android phone but not on a new one.
- You suspect a leak and want evidence before rewriting the screen.
More examples
A photo grid that does not hoard bitmaps
A three-column grid of square thumbnails. The combination that matters for memory is FlatList windowing plus downscaling: only a screenful of views exists, and each holds a thumbnail-sized bitmap rather than a full-resolution one. This is the first thing to change when an image feed crashes on an older Android device.
import { Image } from "expo-image";
import { FlatList, StyleSheet, View } from "react-native";
type Photo = { id: string; uri: string };
type PhotoGridProps = { photos: Photo[] };
export default function PhotoGrid({ photos }: PhotoGridProps) {
return (
<FlatList
data={photos}
keyExtractor={(photo) => photo.id}
numColumns={3}
renderItem={({ item }) => (
<View style={styles.cell}>
<Image
source={{ uri: item.uri }}
style={styles.image}
// When a recycled view gets a new key, it clears the old bitmap
// instead of showing the previous photo for a frame.
recyclingKey={item.id}
// 'disk' is the default. 'memory-disk' is faster and holds more RAM.
cachePolicy="disk"
// Default true: the bitmap is downscaled to the view size.
allowDownscaling
/>
</View>
)}
/>
);
}
const styles = StyleSheet.create({
cell: { flex: 1 / 3, aspectRatio: 1, padding: 2 },
// No background-image and no object-fit here; contentFit is the expo-image prop.
image: { flex: 1, borderRadius: 6 },
});Cancelling a request when the screen goes away
An in-flight fetch holds its response buffer and its promise chain, which hold the component. Aborting on unmount releases both and also fixes a correctness bug: when userId changes quickly, the slower response can no longer overwrite the newer one. React no longer warns about setting state after unmount, so this class of bug is now silent.
import { useEffect, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
type Profile = { id: string; name: string };
type ProfileCardProps = { userId: string };
export default function ProfileCard({ userId }: ProfileCardProps) {
const [profile, setProfile] = useState<Profile | null>(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const response = await fetch(`https://api.example.com/users/${userId}`, {
signal: controller.signal,
});
setProfile((await response.json()) as Profile);
} catch (error) {
// abort() rejects with an AbortError; that is the expected path here.
if ((error as Error).name !== "AbortError") {
setProfile(null);
}
}
}
load();
// Cleanup also runs when userId changes, cancelling the stale request.
return () => controller.abort();
}, [userId]);
return (
<View style={styles.container}>
<Text style={styles.name}>{profile ? profile.name : "Loading"}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 16 },
name: { fontSize: 18, fontWeight: "600" },
});Stopping a poll while the screen is not focused
useFocusEffect runs its callback when the screen gains focus and the returned cleanup when it loses focus, which is what you want for polling, camera sessions and location watchers. The callback must be wrapped in useCallback, otherwise it is a new function every render and the effect restarts in a loop. The cancelled flag stops a response that arrives after the cleanup.
import { useCallback, useState } from "react";
import { useFocusEffect } from "expo-router";
import { StyleSheet, Text, View } from "react-native";
import { fetchQueueDepth } from "../lib/api";
export default function QueueScreen() {
const [depth, setDepth] = useState<number | null>(null);
// A screen you pushed away from is still mounted, so a plain useEffect
// interval would keep polling behind the screen on top of it.
useFocusEffect(
useCallback(() => {
let cancelled = false;
const id = setInterval(async () => {
const next = await fetchQueueDepth();
if (!cancelled) setDepth(next);
}, 5000);
return () => {
cancelled = true;
clearInterval(id);
};
}, []),
);
return (
<View style={styles.container}>
<Text style={styles.value}>{depth === null ? "Loading" : `${depth} waiting`}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: "center", justifyContent: "center" },
value: { fontSize: 20 },
});
Discussion