Performance problems in React Native usually fall into one of three buckets: too many re-renders on the JS thread, too much data crossing the bridge, or expensive work happening synchronously at the wrong time. After eight years and dozens of shipped apps, these are the ten techniques I reach for first — in order of impact.
1. Enable Hermes
Hermes is a JavaScript engine built specifically for React Native. It pre-compiles JS to bytecode at build time, reducing startup time and memory usage significantly. As of React Native 0.70, Hermes is enabled by default. If you're on an older project, turn it on:
// android/app/build.gradle
project.ext.react = [
enableHermes: true,
]
// ios/Podfile
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => true
)
In my testing, Hermes cuts cold-start time by 20–40% on mid-range Android devices — the category where performance complaints are most common.
2. Optimise FlatList Rendering
FlatList is the most common source of jank in React Native apps. The key props to tune:
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={renderItem}
initialNumToRender={10}
maxToRenderPerBatch={5}
windowSize={5}
removeClippedSubviews={true}
getItemLayout={(_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
/>
If all your list items are the same height, getItemLayout is the single biggest win — it eliminates expensive layout measurement on every scroll event.
3. Use React.memo, useMemo, and useCallback Correctly
Wrapping every component in React.memo is a common mistake. Memo only helps if the component's props actually remain referentially stable. Pair it with useCallback for function props:
const renderItem = useCallback(({ item }) => (
<PostCard post={item} onPress={handlePress} />
), [handlePress]);
const PostCard = React.memo(({ post, onPress }) => (
<TouchableOpacity onPress={() => onPress(post.id)}>
<Text>{post.title}</Text>
</TouchableOpacity>
));
Without useCallback, renderItem is a new function reference on every parent render, which defeats React.memo entirely.
4. Always Use the Native Driver for Animations
The JS thread runs at 60 fps — until it doesn't. Any animation driven by the JS thread will stutter when the JS thread is busy. Move animations to the native thread:
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true, // runs on UI thread, immune to JS jank
}).start();
Note: native driver only supports transform and opacity properties. For layout animations, use react-native-reanimated, which runs entirely on the UI thread via its own worklet runtime.
5. Cache Images Aggressively
The default <Image> component has no persistent cache. On Android especially, images re-download on every cold start. Use react-native-fast-image:
import FastImage from 'react-native-fast-image';
<FastImage
source={{
uri: 'https://cdn.example.com/photo.jpg',
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable,
}}
style={{ width: 200, height: 200 }}
/>
FastImage uses SDWebImage on iOS and Glide on Android — both have aggressive disk and memory caches tuned for mobile.
6. Minimise Bridge Traffic
Every call between JS and native has overhead. Batch your native calls, avoid polling native modules on timers, and prefer event-driven patterns. With the New Architecture (Fabric + TurboModules), synchronous JSI calls are available — but the principle still holds: fewer round trips is always faster.
7. Lazy Load Screens with React Navigation
const Stack = createNativeStackNavigator();
// Screen module is loaded only when first navigated to
<Stack.Screen
name="Profile"
getComponent={() => require('./screens/Profile').default}
/>
This defers parsing and executing screen modules until the user actually navigates there, reducing startup bundle evaluation time.
8. Reduce Bundle Size
- Run
npx react-native-bundle-visualizerto identify large dependencies. - Replace
moment.js(329 KB) withdate-fns(~15 KB for common functions). - Use
metro.config.jsto aliaslodashtolodash-esand enable tree-shaking. - Enable Hermes bytecode compression (covered above).
9. Optimise App Startup Time
Cold startup time is the first impression your app makes. Defer all non-critical initialisation:
useEffect(() => {
// Critical path only
initCrashReporting();
InteractionManager.runAfterInteractions(() => {
// Runs after the first frame is painted
initAnalytics();
prefetchUserData();
});
}, []);
10. Profile Before You Optimise
Open Flipper and use the React DevTools plugin to record a render trace before touching a single line of code. You'll often find that the bottleneck is a single component re-rendering 50 times per second — and the fix is a one-line useMemo, not a week of refactoring. Measure first, optimise second.
Premature optimisation is the root of all evil — but uninformed optimisation runs a close second. Know what's slow before you fix it.

