The Performance Myth
There's a persistent belief that React Native is inherently slow, and that JavaScript is the bottleneck. In practice, most performance problems I've encountered come from a different place: unnecessary renders, unoptimized lists, and work on the wrong thread.
If you profile your app and find the JS thread saturated, the problem is almost never "it's React Native" — it's "this code is doing too much."
FlatList Is Your Most Important Component
A ScrollView renders all its children at once. For a list with 50 items, that means 50 components mounted and measured before the user sees anything.
FlatList is virtualized — it only renders items visible on screen plus a small buffer. For any list longer than 10-15 items, use FlatList.
<FlatList
data={transactions}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <TransactionRow transaction={item} />}
getItemLayout={(_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
initialNumToRender={12}
maxToRenderPerBatch={10}
windowSize={5}
removeClippedSubviews
/>
getItemLayout is critical if your items have a fixed height. It lets FlatList skip measuring items during scroll, which eliminates the main source of FlatList jank.
Memo Carefully, Not Everywhere
React.memo prevents re-renders when props haven't changed. But it has a cost — React must compare the old and new props on every parent render. If your component is cheap to render, memo may cost more than it saves.
Use memo when:
- The component is expensive to render (complex layout, image processing)
- It re-renders frequently from a high-level parent
- Its props are stable (primitives or stable object references)
const TransactionRow = React.memo(
function TransactionRow({ transaction }: { transaction: Transaction }) {
return (
<View style={styles.row}>
<Text style={styles.title}>{transaction.description}</Text>
<Text style={styles.amount}>{formatAmount(transaction.amount)}</Text>
</View>
)
},
(prev, next) => prev.transaction.id === next.transaction.id
&& prev.transaction.amount === next.transaction.amount
)
The custom comparator compares only the fields that affect rendering, not the whole object.
useCallback and useMemo Have the Same Cost Warning
Every useCallback allocates a new function on every render, checks the dependencies, and — if they haven't changed — returns the cached one. This is cheaper than re-creating the function, but not free.
The most common place it matters:
function TransactionList({ accountId }: { accountId: string }) {
// Without useCallback, this creates a new function every render,
// causing all memoized TransactionRow items to re-render
const handlePress = useCallback((id: string) => {
navigation.navigate('TransactionDetail', { id })
}, [navigation])
return (
<FlatList
data={transactions}
renderItem={({ item }) => (
<TransactionRow
transaction={item}
onPress={handlePress}
/>
)}
/>
)
}
Without useCallback, handlePress is a new function reference on every render, defeating the React.memo on TransactionRow.
The JS Thread and the UI Thread
React Native has two threads relevant to animation:
- JS thread: runs your JavaScript, including React rendering
- UI thread: handles native layout, touch, and the Animated API
Most jank comes from heavy work blocking the JS thread during scroll. The fix is to move animations off the JS thread entirely using useNativeDriver: true.
const opacity = useRef(new Animated.Value(0)).current
useEffect(() => {
Animated.timing(opacity, {
toValue: 1,
duration: 300,
useNativeDriver: true, // Runs on UI thread — won't be blocked by JS work
}).start()
}, [])
useNativeDriver only works for transform and opacity animations. For layout animations, use LayoutAnimation with care — it's synchronous and can cause layout thrash if overused.
Images Are the Biggest Offender
Unoptimized images are the most common cause of memory pressure and jank in production React Native apps:
- Load the right size — don't load a 2000×2000px image for a 100×100pt avatar
- Use
resizeMode="cover"or"contain"to prevent layout recalculation - Prefer
FastImage(react-native-fast-image) over the built-inImagefor HTTP images — it caches aggressively and reuses connections - For large image lists, set explicit
widthandheighton every image so the layout engine doesn't have to measure
<FastImage
source={{ uri: avatarUrl, priority: FastImage.priority.normal }}
style={{ width: 48, height: 48, borderRadius: 24 }}
resizeMode={FastImage.resizeMode.cover}
/>
Profile Before You Optimize
The React Native Debugger and Flipper both expose the component render profiler. Before you add memo to every component or rewrite a list, spend 10 minutes with the profiler to find the actual bottleneck.
In my experience, 80% of performance issues in production React Native apps come from four things: FlatList without getItemLayout, inline function creation in render, images without explicit dimensions, and heavy synchronous work in useEffect that runs on mount. Fix those first.