Originally published on jahanzaibramzan.com.
Most React Native performance problems I get called in to fix aren't exotic. They're the same handful of issues, repeated: lists that re-render everything on every keystroke, images that are ten times larger than the box they sit in, screens that fetch more data than they show, and a startup path that does far too much work before the first frame. This is the list I actually work through on client apps, roughly in the order I check them.
1. Measure before you touch anything
Performance work without measurement is guessing. Before changing code, get a baseline for the three things users feel: time to first interactive screen, frame drops during scroll and animation, and the delay between a tap and visible feedback.
The built-in Performance Monitor (shake → Perf Monitor) shows JS and UI frame rates. For anything deeper use Flipper with the React DevTools plugin, or the React Native DevTools profiler. On Android, adb shell dumpsys gfxinfo <package> gives frame timing without any extra tooling.
Write the numbers down. Then when you apply one of the fixes below, you'll know whether it helped or whether you just made the code more complicated.
2. Replace FlatList with FlashList for long lists
FlatList recreates list item components as they scroll into view. On a feed with hundreds of items and images, that's the single biggest source of dropped frames I see. Shopify's @shopify/flash-list recycles item views instead, and on the same data it typically cuts scroll jank dramatically.
import { FlashList } from "@shopify/flash-list";
<FlashList
data={posts}
renderItem={({ item }) => <PostCard post={item} />}
estimatedItemSize={180}
keyExtractor={(item) => item.id}
/>
The one thing it needs is estimatedItemSize — an honest guess at the average row height. Get that roughly right and the rest is a drop-in replacement. If you can't migrate yet, at least set windowSize, maxToRenderPerBatch, and removeClippedSubviews on FlatList — but migrate.
3. Stop list items from re-rendering
Even with FlashList, if every row re-renders when unrelated state changes, you're back to jank. Two habits fix most of it.
Wrap row components in React.memo, and make sure the props you pass are stable:
const PostCard = React.memo(function PostCard({ post, onPress }: Props) {
return (
<Pressable onPress={() => onPress(post.id)}>
<Text>{post.title}</Text>
</Pressable>
);
});
// In the parent — stable callback, not a new arrow function per render
const handlePress = useCallback((id: string) => navigate("Post", { id }), [navigate]);
The common mistake is memoizing the child and then passing it a fresh { style: {...} } object or inline function on every render, which defeats the memo entirely. If a memoized component still re-renders, the React DevTools "Highlight updates" toggle will show you which prop changed.
4. Size images for the box they fill
A 4000×3000 photo rendered into a 120×90 thumbnail still has to be decoded at full size. Multiply that by a scrolling list and you've got memory pressure, decode stalls, and OOM crashes on low-end Android.
Serve images at roughly the display size (2× for retina is fine), and cache them on disk. expo-image handles caching, placeholders, and transitions well; react-native-fast-image does the same on bare RN.
import { Image } from "expo-image";
<Image
source={{ uri: post.thumbUrl }} // a 240px variant, not the original
style={{ width: 120, height: 90 }}
contentFit="cover"
cachePolicy="memory-disk"
placeholder={blurhash}
/>
If your backend is Firebase Storage, generate resized variants at upload time with the Resize Images extension rather than resizing on the client.
5. Make sure Hermes is on, and check your bundle
Hermes has been the default engine for a while, but I still find apps that turned it off during a migration and never turned it back on. Confirm it in android/gradle.properties (hermesEnabled=true) and the iOS Podfile, then check at runtime:
const isHermes = () => !!(global as any).HermesInternal;
While you're there, look at what's actually in your JS bundle. npx react-native-bundle-visualizer shows it as a treemap. Every time I run it on a client project there's something surprising — a full lodash import for two functions, moment with every locale, an icon library pulling in thousands of glyphs. Swapping to lodash/debounce, date-fns, and a tree-shakeable icon set can cut startup by hundreds of milliseconds.
6. Move work off the startup path
Cold start is where apps feel slowest, and it's usually self-inflicted. The pattern to look for: the root component fetching remote config, hydrating a persisted store, initialising analytics, checking auth, and registering push notifications — all before rendering anything.
Split it into what the first screen actually needs and what can wait:
useEffect(() => {
// Needed to render the first screen
restoreAuthSession();
// Everything else after first paint
InteractionManager.runAfterInteractions(() => {
initAnalytics();
registerForPushNotifications();
prefetchSecondaryData();
});
}, []);
Persisted state (Redux Persist, Zustand's persist) should only include what you need on launch; a 2 MB cached feed in AsyncStorage is a startup cost, not a feature. Lazy-load heavy screens with React.lazy so their code isn't parsed until someone navigates there.
7. Keep animations on the UI thread
Any animation driven by JS state updates competes with everything else the JS thread is doing, so it stutters the moment a list scrolls or a network response arrives. Reanimated runs animations as worklets on the UI thread and stays smooth under load.
const progress = useSharedValue(0);
const style = useAnimatedStyle(() => ({
opacity: progress.value,
transform: [{ translateY: (1 - progress.value) * 20 }],
}));
useEffect(() => {
progress.value = withTiming(1, { duration: 250 });
}, []);
For gestures, pair it with react-native-gesture-handler so the whole interaction stays off the JS thread. For the old Animated API, useNativeDriver: true gets you part of the way for opacity and transforms.
8. Fetch less, cache more
Slow screens are often just waiting on the network. Three things help.
First, don't fetch on every focus. TanStack Query (or SWR) gives you stale-while-revalidate for free: show cached data immediately, refresh in the background.
const { data } = useQuery({
queryKey: ["post", id],
queryFn: () => fetchPost(id),
staleTime: 60_000,
});
Second, fetch only what the screen shows. A list needs titles and thumbnails, not the full body of every item.
Third, if you're on Firestore: enable offline persistence (it's on by default on mobile), model data so a screen is one or two reads rather than a fan-out of N queries, and precompute counters and aggregates in Cloud Functions instead of counting on the client. Firestore bills per document read and each read is a round trip — both add up fast on a feed.
9. Watch for expensive re-renders from context and stores
A single React.Context holding the whole app state means every consumer re-renders whenever any field changes. The same applies to selecting the whole store from Redux or Zustand.
Split contexts by how often they change (auth vs. theme vs. feed data), and select narrowly:
// Re-renders only when `unreadCount` changes
const unread = useStore((s) => s.notifications.unreadCount);
// Re-renders on every store update — avoid
const store = useStore();
why-did-you-render is worth adding in development for a week; it's blunt but it finds these fast.
10. Test on the phone your users actually have
Everything above will look fine on an iPhone 15 or a Pixel 8. Most of the world isn't on those. Keep one genuinely cheap Android device — something with 3–4 GB of RAM from a few years ago — and make it part of the QA pass before every release. Enable Don't keep activities in developer options once in a while too; it exposes state-restoration bugs that only appear when the OS kills your app in the background.
Release builds only. Debug builds run the JS through the Metro dev server with extra checks, and are not representative of what users get.
A workflow that sticks
The fixes above are fairly mechanical. What keeps an app fast is doing them consistently: set a performance budget (for example, first screen under 1.5 s on the test device, no dropped frames on the main feed), check it in CI or at least before each release, and treat a regression like a bug rather than a polish task for later.
I've applied this checklist on apps ranging from a Quran-learning app with 300K+ users on Firebase to solo Android projects, and the outcome is usually the same: the first two or three items fix most of the complaints, and the rest keep them from coming back.
If you have a React Native app that has gotten slow and you'd like a second pair of eyes on it, I do technical audits — see Services or get in touch.
Top comments (0)