React Native Performance: What Nobody Tells You
The Real Bottleneck
Most React Native performance advice starts with "use FlatList instead of ScrollView" and ends there. That's fine for beginners, but production apps with complex UIs hit problems that go much deeper.
The root cause of most React Native jank is the JavaScript thread doing too much work during renders. Every setState, every re-render, every anonymous function passed as a prop — it all runs on one thread. And if that thread is busy, your UI freezes.
Understand the Architecture First
React Native has two main threads:
- JS thread — runs your JavaScript logic and React reconciliation
- UI thread (main thread) — renders native views and handles gestures
They communicate over a bridge (or the new JSI in the new architecture). That communication has a cost. Sending large payloads or calling it too frequently is where performance dies.
// ❌ This serializes a large object across the bridge on every render
<FlatList
data={items}
renderItem={({ item }) => <ExpensiveComponent data={item} onPress={() => handlePress(item)} />}
/>
// ✅ Extract and memoize the render function
const renderItem = useCallback(({ item }) => (
<ExpensiveComponent data={item} onPress={handlePress} />
), [handlePress])
<FlatList data={items} renderItem={renderItem} />
FlatList: The Wrong Default Settings
FlatList's defaults are not tuned for performance. These props matter:
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={item => item.id}
// Render 10 items outside the viewport (instead of default 21)
windowSize={5}
// Remove items from memory when far off-screen
removeClippedSubviews={true}
// How many items to render in the initial batch
initialNumToRender={10}
// Max items rendered per JS frame
maxToRenderPerBatch={5}
// Delay between batch renders (ms)
updateCellsBatchingPeriod={30}
getItemLayout={(data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
/>
getItemLayout is the biggest win if your items have a fixed height. It lets FlatList skip measuring and jump directly to any scroll position.
Memoization Is Not Free
React.memo, useMemo, and useCallback have overhead. They run a comparison on every render. For simple values the comparison costs more than just re-rendering.
Use them when:
- The component is genuinely expensive to render
- The prop is a function or object that would fail reference equality
// ❌ Pointless — comparing a string is as cheap as the memo overhead
const Title = React.memo(({ text }) => <Text>{text}</Text>)
// ✅ Worth it — prevents re-rendering a complex list item
const ProductCard = React.memo(({ product, onAddToCart }) => {
// ... complex render with images, animations
}, (prev, next) => prev.product.id === next.product.id)
Move Work Off the JS Thread
For animations, use react-native-reanimated. It runs animations on the UI thread directly, bypassing the bridge entirely.
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated'
const offset = useSharedValue(0)
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offset.value }],
}))
// This runs on the UI thread — zero JS thread involvement
offset.value = withSpring(100)
For heavy computations (parsing, sorting, transforming large datasets), offload to a worker with react-native-workers or batch the work using InteractionManager:
InteractionManager.runAfterInteractions(() => {
// Runs after animations and interactions settle
const processed = heavyDataTransformation(rawData)
setData(processed)
})
The Profiler Is Your Best Friend
Before optimizing anything, profile first. In Flipper, the React DevTools Profiler shows exactly which components re-render and why. In 90% of cases, the problem is one component re-rendering with stale props — not a global architectural issue.
Fix the specific thing the profiler shows. Don't blindly wrap everything in memo.
Enjoyed this post?
Subscribe to the newsletter
Get future posts delivered to your inbox. No spam, unsubscribe anytime.