Most React Native performance problems are not caused by React Native itself. They come from doing too much work at the wrong time, on the wrong thread, or too often. The fix for each one is different, which is why guessing usually wastes time.
This article walks through the most common causes of slow apps, the techniques that address them, and how to confirm a change helped. No technique here is a guaranteed win. Each one helps only if it targets your actual bottleneck.
What "performance" means in a React Native app
Users don't care about your render count. They notice:
Slow startup: a long time before the first usable screen.
Dropped frames: janky scrolling, stuttering animations, laggy gestures.
Delayed input response: taps or typing that feel late.
Resource use: memory growth, battery drain, heavy network traffic.
Mobile app performance is judged on a device in someone's hand, not on a fast development machine. Always test on a mid-range or older device, in a release build.
Understand the threads first
To fix React Native rendering problems you need a rough model of where work happens:
JavaScript thread: runs your React code, business logic, state updates, and the render phase that decides what the UI should look like.
UI (main) thread: runs native views, layout results, touch handling, and drawing.
If the JS thread is busy, JS-driven work stalls: onPress handlers fire late, JS-driven animations stutter, and state updates take longer to appear. If the UI thread is busy (for example, drawing very large or complex views), scrolling and native animations suffer even though your JS is idle.
The New Architecture (Fabric and TurboModules, the default in recent React Native versions) reduces some of the old bridge overhead, but it does not remove the basic rule: expensive JavaScript still blocks the JS thread. Hermes is the default engine on current versions, so Hermes-based profiling is what you'll mostly use.
Common causes of slow apps
In roughly the order I'd check them:
Components re-rendering far more often than necessary
Long lists rendered with heavy row components
Expensive calculations inside render
Large, unoptimized images
Duplicate or unnecessary network requests
Frequent state updates (scroll position, text input) pushed through React state
JS-driven animations competing with other JS work
A large bundle or heavy dependencies slowing startup
Measure before you optimize
Profile first, change one thing, then profile again.
React Native DevTools (available in recent versions) gives you the React Profiler, a component tree, and a JS debugger. The Profiler shows which components rendered and why.
The in-app Performance Monitor (from the dev menu) gives a rough view of JS and UI frame rates. Treat it as a hint, not a measurement.
Hermes sampling profiler shows where JS time is spent.
Xcode Instruments and Android Studio Profiler show native CPU, memory, and rendering cost.
Dev mode is much slower than release mode, so confirm findings in a release build before drawing conclusions.
For targeted checks, React's Profiler component works well:
tsx
import React, { Profiler, ProfilerOnRenderCallback } from 'react';
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration) => {
console.log(${id} [${phase}] rendered in ${actualDuration.toFixed(1)}ms);
};
export function ProfiledList({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
This logs how long a subtree took to render each time it commits. Compare the numbers before and after a change, on the same device and the same interaction.
Reduce unnecessary re-renders
A component re-renders when its state changes, its parent re-renders, or a context it consumes changes. Re-rendering alone isn't a problem. It becomes one when it's frequent and expensive.
React.memo, useCallback, and useMemo
React.memo skips re-rendering a component when its props are shallowly equal. It only works if props are actually stable, which is where useCallback and useMemo come in.
Before: every parent render creates a new onSelect function, so every row re-renders.
tsx
function ProductList({ products }: { products: Product[] }) {
const [selectedId, setSelectedId] = useState(null);
return (
data={products}
keyExtractor={(item) => item.id}
extraData={selectedId}
renderItem={({ item }) => (
item={item}
isSelected={item.id === selectedId}
onSelect={(id) => setSelectedId(id)}
/>
)}
/>
);
}
After: stable callbacks plus a memoized row.
tsx
import React, { memo, useCallback, useState } from 'react';
import { FlatList, ListRenderItemInfo, Pressable, Text, StyleSheet } from 'react-native';
type Product = { id: string; title: string; price: number };
type RowProps = {
item: Product;
isSelected: boolean;
onSelect: (id: string) => void;
};
const ProductRow = memo(function ProductRow({ item, isSelected, onSelect }: RowProps) {
return (
onPress={() => onSelect(item.id)}
style={[styles.row, isSelected && styles.selected]}
>
{item.title}
${item.price.toFixed(2)}
);
});
const keyExtractor = (item: Product) => item.id;
export function ProductList({ products }: { products: Product[] }) {
const [selectedId, setSelectedId] = useState(null);
const handleSelect = useCallback((id: string) => setSelectedId(id), []);
const renderItem = useCallback(
({ item }: ListRenderItemInfo) => (
item={item}
isSelected={item.id === selectedId}
onSelect={handleSelect}
/>
),
[selectedId, handleSelect]
);
return (
data={products}
keyExtractor={keyExtractor}
renderItem={renderItem}
extraData={selectedId}
/>
);
}
const styles = StyleSheet.create({
row: { padding: 16, flexDirection: 'row', justifyContent: 'space-between' },
selected: { backgroundColor: '#e8f0fe' },
});
Now when selectedId changes, only the rows whose isSelected value changed re-render. extraData is needed so FlatList knows to re-render rows when something outside data changes.
A note on the React Compiler: recent React Native versions can use it to apply automatic memoization at build time. If you adopt it, you'll need less manual useMemo and useCallback, but you should still profile, since it doesn't fix expensive work or poor data flow.
Avoid unnecessary state updates
Not every changing value belongs in useState. Anything that updates frequently but doesn't affect what's rendered can live in a ref:
tsx
import { useCallback, useRef } from 'react';
import { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
const lastScrollY = useRef(0);
const onScroll = useCallback((e: NativeSyntheticEvent) => {
lastScrollY.current = e.nativeEvent.contentOffset.y; // no re-render
}, []);
Other habits that help:
Keep state as local as possible. Putting a text input's value in a top-level store re-renders far more than needed.
Skip updates when the value hasn't changed.
Split large contexts so a frequently changing value doesn't invalidate unrelated consumers.
Batching is automatic in React 18 and later, so you don't need to manually combine most setState calls.
Avoid expensive work during render
Anything inside a component body runs on every render. Filtering, sorting, and parsing large arrays are common offenders.
tsx
import React, { useMemo, useState } from 'react';
import { TextInput } from 'react-native';
function SearchableList({ items }: { items: Product[] }) {
const [query, setQuery] = useState('');
const visibleItems = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter((i) => i.title.toLowerCase().includes(q));
}, [items, query]);
return (
<>
</>
);
}
useMemo here avoids re-filtering when unrelated state changes. It won't make the filter itself faster. If filtering on every keystroke is still too slow, debounce the query or use useDeferredValue so typing stays responsive:
tsx
const deferredQuery = useDeferredValue(query);
Don't wrap everything in useMemo. Memoization has its own cost, and for cheap calculations it adds complexity without a measurable gain.
FlatList optimization
FlatList virtualizes rows, rendering only what's near the viewport. Its defaults are reasonable, but large or complex lists often need tuning.
tsx
const ROW_HEIGHT = 56;
data={products}
keyExtractor={keyExtractor}
renderItem={renderItem}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={7}
removeClippedSubviews
getItemLayout={(_, index) => ({
length: ROW_HEIGHT,
offset: ROW_HEIGHT * index,
index,
})}
/>
What these do:
keyExtractor: stable, unique keys help React reuse rows correctly. Avoid using the array index if items can be reordered or removed.
getItemLayout: only valid when every row has a fixed, known height. It lets the list skip measuring and makes scrollToIndex reliable.
initialNumToRender, maxToRenderPerBatch, windowSize: trade off initial render cost, scroll smoothness, and memory. A lower windowSize uses less memory but can show blank areas during fast scrolling.
removeClippedSubviews: can reduce the native view count on some setups, but it has had edge-case bugs, so test it before shipping.
Beyond props, keep row components cheap: avoid deeply nested layouts, inline object styles that change identity, and anonymous functions that defeat memo.
If you've done the above and still struggle with very large lists, consider FlashList from Shopify, which recycles views instead of destroying and recreating them. Check its documentation for requirements, and benchmark it against your own list rather than assuming it's faster for your case.
Image optimization and caching
Images are a frequent source of memory pressure and slow lists.
Serve images at roughly the size you display them. Decoding a 4000px photo for a 100px thumbnail wastes memory.
Prefer efficient formats such as WebP where your platform and backend support it.
Use a library with proper disk and memory caching. React Native's built-in Image handles basic cases, but expo-image provides configurable caching and placeholders and can also be used in bare React Native apps:
tsx
import { Image } from 'expo-image';
source={{ uri: product.imageUrl }}
style={{ width: 80, height: 80 }}
contentFit="cover"
cachePolicy="memory-disk"
transition={150}
/>
cachePolicy="memory-disk" caches images in memory and on disk, so scrolling back through a list doesn't re-download them. Always set explicit width and height so layout doesn't shift as images load.
Reduce unnecessary network requests
A request you don't make is faster than any optimization of the request.
Common waste includes refetching on every mount, firing a request per keystroke, and fetching the same resource from several components. A data-fetching library with caching handles most of this. With TanStack Query:
tsx
import { useQuery } from '@tanstack/react-query';
function useProduct(id: string) {
return useQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
staleTime: 60_000, // treat data as fresh for 1 minute
});
}
Multiple components calling useProduct('42') share one cached entry and one in-flight request. staleTime controls how long cached data is considered fresh before a background refetch. Tune it to how often your data actually changes.
For search inputs, debounce:
tsx
import { useEffect, useState } from 'react';
function useDebouncedValue(value: T, delayMs = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
Use the debounced value as the query key or request parameter, so the network is hit after the user pauses rather than on every character. Also consider pagination for large collections, and cancel requests that are no longer relevant.
Manage animations efficiently
Animations feel bad when frames are dropped, and frames get dropped when the thread driving the animation is busy.
With the built-in Animated API, useNativeDriver: true sends the animation definition to the native side, so it runs without involving the JS thread on every frame:
tsx
import React, { useEffect, useRef } from 'react';
import { Animated } from 'react-native';
function FadeIn() {
const opacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(opacity, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}).start();
}, [opacity]);
return ;
}
The native driver only supports non-layout properties such as opacity and transform. Animating width, height, or flex this way isn't supported.
For gesture-driven or more complex animations, React Native Reanimated runs worklets on the UI thread:
tsx
import React from 'react';
import { Button, StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated';
function Box() {
const offset = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offset.value }],
}));
return (
<>
title="Move"
onPress={() => {
offset.value = withSpring(offset.value + 100);
}}
/>
</>
);
}
const styles = StyleSheet.create({
box: { width: 80, height: 80, backgroundColor: '#4f46e5' },
});
Updating offset.value doesn't trigger a React re-render. The style is computed on the UI thread. Check the Reanimated docs for the version that matches your React Native version.
Also avoid driving animations through setState. That forces a render on every frame.
Bundle size and dependencies
A larger JS bundle generally means more to load at startup, though Hermes' precompiled bytecode softens this. Ways to keep it in check:
Audit dependencies. Large utility libraries imported for one function are a common culprit. Import specific functions or write the small helper yourself.
Visualize your bundle. Tools such as Expo Atlas (for Expo projects) or community bundle visualizers show what's taking space.
Remove unused packages and dead code paths.
Defer non-critical work: lazy-load heavy screens where your navigation setup supports it, and use InteractionManager.runAfterInteractions for work that can wait until transitions finish.
Hermes is the default in current versions, and you should always measure startup in release builds.
Common optimization mistakes
Optimizing without profiling. You'll often fix the wrong thing.
Wrapping everything in memo, useMemo, and useCallback. Unstable props, such as inline objects, still break memoization, and unnecessary memoization adds overhead.
Using the array index as a list key for dynamic lists.
Setting getItemLayout with incorrect heights, which causes scroll jumps.
Judging performance in debug mode or only on a high-end device.
Storing fast-changing values in global state that many components subscribe to.
Animating layout properties through JS on every frame.
Over-tuning FlatList props without checking for blank areas during fast scrolls.
Troubleshooting checklist
When something feels slow, work through this:
Reproduce it in a release build on a real, mid-range device.
Identify the symptom: slow startup, dropped frames, or delayed taps.
Check whether the JS thread or UI thread is the bottleneck.
Use the React Profiler to find components that render often or slowly.
Use the Hermes profiler for long-running JS functions.
Inspect network activity for duplicate or oversized requests.
Change one thing, re-measure, and keep the change only if the numbers improve.
Conclusion
React Native performance optimization is mostly about finding the real bottleneck. Sometimes it's a list rendering too much, sometimes an oversized image, sometimes a chatty API, and sometimes a JS function blocking the thread for too long. The techniques above are all legitimate, but none is a universal fix.
Start with measurement, make focused changes, and verify them on real devices in release builds. That habit will do more for your app's speed than any single trick.
Top comments (0)