Performance2024-10-05|7 min read

10 React Native Performance Tips That Actually Work

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-visualizer to identify large dependencies.
  • Replace moment.js (329 KB) with date-fns (~15 KB for common functions).
  • Use metro.config.js to alias lodash to lodash-es and 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.