React Native Performance Optimization

A well-written React Native app runs at 60 frames per second. Every frame takes 16 milliseconds. When any operation blocks that 16ms window, the screen stutters. This topic covers the most impactful techniques to keep your app fast and responsive.

Understanding the Two Threads

JavaScript Thread                  Native (UI) Thread
──────────────────────────────────────────────────────────
Runs your React code               Renders pixels on screen
Handles state, logic, events       Runs animations
Communicates over the bridge       Must stay at 60fps

If your JS thread is busy:
└── bridge is delayed
    └── native thread waits
        └── animation stutters ← user sees this

The Most Common Performance Mistakes

Mistake                          Fix
──────────────────────────────────────────────────────────────
Inline style objects             Use StyleSheet.create
Anonymous functions in render    useCallback
Rendering all list items at once Use FlatList (not ScrollView)
Missing keyExtractor on FlatList Always add keyExtractor
Large images, not resized        Use correct image dimensions
Re-rendering the whole tree      Memoize components (React.memo)
Importing unused libraries       Tree-shaking and lazy imports

React.memo — Skip Unnecessary Re-renders

When a parent re-renders, all its children re-render by default — even children whose props did not change. Wrap a component in React.memo to skip re-renders when its props are unchanged.

// Without memo: re-renders every time parent re-renders
function ContactRow({ name, phone }) {
  return (
    <View>
      <Text>{name}</Text>
      <Text>{phone}</Text>
    </View>
  );
}

// With memo: skips re-render if name and phone didn't change
const ContactRow = React.memo(function ContactRow({ name, phone }) {
  return (
    <View>
      <Text>{name}</Text>
      <Text>{phone}</Text>
    </View>
  );
});
Without React.memo:              With React.memo:
Parent renders                   Parent renders
  ↓                               ↓
ContactRow re-renders ✗          Did props change?
ContactRow re-renders ✗            → No  → skip ✓
ContactRow re-renders ✗            → Yes → re-render ✓
(wasted work)                    (only when needed)

useCallback — Stable Function References

Every render creates a new function in memory. Passing a new function as a prop breaks React.memo because the prop technically changed. useCallback keeps the same function reference between renders.

// Bad: new function created on every render, breaks React.memo on ContactRow
function ContactList({ contacts }) {
  function handlePress(id) {
    navigation.navigate('Detail', { id });
  }

  return <FlatList data={contacts} renderItem={({ item }) => <ContactRow onPress={handlePress} .../>} />;
}

// Good: same function reference unless navigation changes
function ContactList({ contacts }) {
  const handlePress = useCallback((id) => {
    navigation.navigate('Detail', { id });
  }, [navigation]);

  return <FlatList data={contacts} renderItem={({ item }) => <ContactRow onPress={handlePress} .../>} />;
}

FlatList Optimisations

<FlatList
  data={items}
  keyExtractor={(item) => item.id}
  renderItem={renderItem}

  // Performance props:
  removeClippedSubviews={true}    // unmount items far off screen (Android)
  maxToRenderPerBatch={10}        // render 10 items per batch
  initialNumToRender={8}          // render only 8 items on first load
  windowSize={5}                  // keep 5 screen-heights worth of items in memory
  getItemLayout={(data, index) => ({
    length: 80,                   // fixed row height in pixels
    offset: 80 * index,           // position of this row
    index,
  })}
  // getItemLayout eliminates measuring overhead — use it when all rows are the same height
/>

Image Optimisation

// Bad: loads a 4000×3000 image and shrinks it with CSS
<Image source={{ uri: 'https://example.com/huge-photo.jpg' }} style={{ width: 80, height: 80 }} />

// Good: request a thumbnail-sized image from the server
<Image source={{ uri: 'https://example.com/photo-80x80.jpg' }} style={{ width: 80, height: 80 }} />

Use FastImage from react-native-fast-image for better image caching and loading speed in production apps.

npm install react-native-fast-image

import FastImage from 'react-native-fast-image';

<FastImage
  source={{
    uri: 'https://example.com/photo.jpg',
    priority: FastImage.priority.normal,
  }}
  style={{ width: 80, height: 80, borderRadius: 40 }}
  resizeMode={FastImage.resizeMode.cover}
/>

Lazy Loading Screens

Tab navigators load all tab screens at startup by default. Set lazy to true so each tab only loads when the user visits it.

<Tab.Navigator screenOptions={{ lazy: true }}>
  <Tab.Screen name="Home"    component={HomeScreen} />
  <Tab.Screen name="Search"  component={SearchScreen} />
  <Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>

Hermes Engine

Hermes is a JavaScript engine built by Meta specifically for React Native. It pre-compiles JavaScript at build time, which reduces startup time and memory usage. Expo enables Hermes by default in modern versions.

// app.json — verify Hermes is enabled
{
  "expo": {
    "android": { "jsEngine": "hermes" },
    "ios":     { "jsEngine": "hermes" }
  }
}

Profiling with Flipper and React DevTools

Never guess at performance issues — measure them. Use React DevTools Profiler to see which components re-render too often and how long each render takes.

1. Install React Native Debugger or connect Flipper
2. Open Components tab → find components highlighted in yellow (re-rendered)
3. Open Profiler tab → record a session → see render times

Flame chart:
┌─────────────────────────────────────────────┐
│ App          ████░░░░░░░░░░░░  4.2ms        │
│  FlatList    ████░░  2.1ms                  │
│   ContactRow █  0.3ms  ← fast, green        │
│   ContactRow █  0.3ms                       │
│   ContactRow ████████  3.5ms ← slow, red    │
└─────────────────────────────────────────────┘

Avoiding the Most Expensive Operations

Operation                       Cost    Alternative
────────────────────────────────────────────────────────────────
Parsing large JSON in render    High    Parse in useEffect
Heavy calculations every render High    useMemo
Network calls in renderItem     High    Pre-fetch data before rendering
Shadows on Android              Medium  Use elevation, limit shadow views
Transparent backgrounds         Medium  Set opaque background where possible
Setting state inside loops      High    Batch into one setState call

InteractionManager — After Animations

Run expensive operations after the current animation or transition completes, so the UI stays responsive during the transition.

import { InteractionManager } from 'react-native';

useEffect(() => {
  // Wait until screen transition animation finishes
  InteractionManager.runAfterInteractions(() => {
    // Now safe to run expensive work
    loadHeavyData();
  });
}, []);

Summary

Keep the JavaScript thread free for UI work. Wrap stable components in React.memo and stable functions in useCallback. Always use FlatList for lists and add getItemLayout for fixed-height rows. Size images correctly at the source. Enable lazy loading for tabs. Use Hermes in production builds. Profile with React DevTools before optimising — measure first, then fix.

Leave a Comment