React Native Performance Optimisation Deep Dive

React Native has matured significantly, and in 2022 it powers production apps at scale for companies like Shopify, Discord, and Coinbase. But performance does not come automatically. Without deliberate optimisation, React Native apps can feel sluggish compared to their native counterparts.

This guide covers the performance techniques that make the biggest practical difference, based on our experience building React Native apps for Australian businesses.

Understanding the Architecture

Understanding the Architecture Infographic

Before optimising, you need to understand how React Native works under the hood.

React Native runs your JavaScript code on a separate thread from the UI thread. Communication between these two threads happens over the Bridge, a serialised message queue. Every time your JS code needs to update the UI, or the UI needs to notify your JS code of an event, data is serialised, sent across the bridge, and deserialised on the other side.

This architecture means:

  • Heavy JS computation blocks the JS thread, making the app unresponsive to user input
  • Frequent bridge communication creates serialisation overhead
  • The UI thread runs natively and is fast, but only if it is not waiting on the bridge

Most React Native performance issues stem from either blocking the JS thread or flooding the bridge with messages.

Rendering Opti

misation

Prevent Unnecessary Re-renders

The single most impactful optimisation is preventing unnecessary re-renders. React re-renders a component whenever its parent re-renders, even if the component’s own props have not changed.

// Problem: UserAvatar re-renders every time the parent re-renders
const UserList = ({ users, onRefresh }) => {
  return (
    <FlatList
      data={users}
      renderItem={({ item }) => (
        <View style={styles.row}>
          <UserAvatar user={item} />
          <Text>{item.name}</Text>
        </View>
      )}
    />
  );
};

// Solution: Memoize the row component
const UserRow = React.memo(({ user }) => {
  return (
    <View style={styles.row}>
      <UserAvatar user={user} />
      <Text>{user.name}</Text>
    </View>
  );
});

const UserList = ({ users, onRefresh }) => {
  const renderItem = useCallback(
    ({ item }) => <UserRow user={item} />,
    []
  );

  return (
    <FlatList
      data={users}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
    />
  );
};

const keyExtractor = (item) => item.id;

useMemo and useCallback

Use useMemo for expensive computations and useCallback for function references passed as props:

const SearchResults = ({ items, searchQuery }) => {
  // Memoize expensive filtering
  const filteredItems = useMemo(() => {
    return items.filter(item =>
      item.name.toLowerCase().includes(searchQuery.toLowerCase())
    );
  }, [items, searchQuery]);

  // Memoize callbacks passed to children
  const handlePress = useCallback((item) => {
    navigation.navigate('Detail', { id: item.id });
  }, [navigation]);

  return (
    <FlatList
      data={filteredItems}
      renderItem={({ item }) => (
        <SearchResultRow item={item} onPress={handlePress} />
      )}
    />
  );
};

Avoid Inline Objects and Functions

Inline objects and functions create new references on every render, defeating React.memo:

// Bad: new style object every render
<View style={{ padding: 16, backgroundColor: '#fff' }}>

// Good: static styles outside the component
const styles = StyleSheet.create({
  container: { padding: 16, backgroundColor: '#fff' },
});
<View style={styles.container}>

// Bad: new function every render
<TouchableOpacity onPress={() => handlePress(item.id)}>

// Good: memoized or extracted handler
const handlePress = useCallback(() => {
  onItemPress(item.id);
}, [item.id, onItemPress]);
<TouchableOpacity onPress={handlePress}>

FlatList Opti

misation

FlatList is the workhorse component for lists in React Native, and it is where many performance problems surface.

Essential FlatList Props

<FlatList
  data={items}
  renderItem={renderItem}
  keyExtractor={keyExtractor}

  // Performance props
  removeClippedSubviews={true}        // Unmount off-screen items
  maxToRenderPerBatch={10}             // Items rendered per batch
  updateCellsBatchingPeriod={50}       // Time between batch renders (ms)
  initialNumToRender={10}              // Items rendered on first load
  windowSize={5}                       // Number of viewports to keep rendered
  getItemLayout={getItemLayout}        // Skip layout measurement

  // Only if items have fixed height
  getItemLayout={(data, index) => ({
    length: ITEM_HEIGHT,
    offset: ITEM_HEIGHT * index,
    index,
  })}
/>

getItemLayout

If your list items have a consistent height, providing getItemLayout eliminates the need for React Native to measure each item, significantly improving scroll performance and enabling instant scroll-to-index:

const ITEM_HEIGHT = 72;
const SEPARATOR_HEIGHT = 1;

const getItemLayout = (data, index) => ({
  length: ITEM_HEIGHT,
  offset: (ITEM_HEIGHT + SEPARATOR_HEIGHT) * index,
  index,
});

Virtualization Tuning

The windowSize prop controls how many screens worth of content are rendered above and below the visible area. The default is 21 (10 screens above, the visible screen, and 10 below). For most apps, reducing this to 5 saves significant memory:

<FlatList
  windowSize={5}     // 2 screens above + visible + 2 below
  maxToRenderPerBatch={5}
/>

Image Opti

misation

Images are often the largest performance bottleneck in mobile apps.

Use FastImage

The built-in Image component has known performance issues, particularly with caching. Use react-native-fast-image instead:

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

const ProductImage = ({ uri }) => (
  <FastImage
    style={styles.image}
    source={{
      uri,
      priority: FastImage.priority.normal,
      cache: FastImage.cacheControl.immutable,
    }}
    resizeMode={FastImage.resizeMode.cover}
  />
);

Resize on the Server

Never download a 4000x3000 pixel image to display in a 200x200 container. Use server-side image resizing or a CDN with transformation capabilities:

const getOptimisedImageUrl = (url, width, height) => {
  // Example with Cloudinary
  return url.replace(
    '/upload/',
    `/upload/w_${width},h_${height},c_fill,q_auto,f_auto/`
  );
};

<FastImage
  source={{ uri: getOptimisedImageUrl(imageUrl, 400, 400) }}
  style={{ width: 200, height: 200 }}
/>

Preload Critical Images

For images that appear immediately on screen, preload them:

useEffect(() => {
  FastImage.preload([
    { uri: heroImageUrl },
    { uri: userAvatarUrl },
  ]);
}, [heroImageUrl, userAvatarUrl]);

React Navigation Optimisation

React Navigation is the standard navigation library for React Native. Key optimisations:

// Lazy load screens
const ProfileScreen = React.lazy(() => import('./screens/ProfileScreen'));

// Or use React Navigation's built-in lazy loading
<Stack.Screen
  name="Profile"
  getComponent={() => require('./screens/ProfileScreen').default}
/>

// Freeze inactive screens to prevent re-renders
import { enableFreeze } from 'react-native-screens';
enableFreeze(true);

Avoid Heavy Screen Transitions

Screen transitions should feel instant. If a screen takes time to render, show a placeholder and load content asynchronously:

const HeavyScreen = () => {
  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    // Wait for transition to complete before rendering heavy content
    const timeout = setTimeout(() => setIsReady(true), 100);
    return () => clearTimeout(timeout);
  }, []);

  if (!isReady) {
    return <ScreenPlaceholder />;
  }

  return <HeavyContent />;
};

Bridge Communication

Minimise Bridge Traffic

Every cross-bridge call has overhead. Batch operations where possible:

// Bad: Multiple individual bridge calls
items.forEach(item => {
  NativeModule.processItem(item);
});

// Better: Single batch call
NativeModule.processItems(items);

Use Hermes Engine

Hermes is a JavaScript engine optimised for React Native. It improves startup time, reduces memory usage, and decreases app size. Enable it in your build configuration:

// android/app/build.gradle
project.ext.react = [
    enableHermes: true
]

For iOS, Hermes is available starting from React Native 0.64:

# Podfile
use_react_native!(
  :hermes_enabled => true
)

Hermes typically reduces:

  • App startup time by 30 to 50 percent
  • Memory usage by 20 to 30 percent
  • Download size by several megabytes

Memory Management

Monitor Memory Usage

Use Flipper or the React Native performance monitor to track memory:

// Enable the performance monitor in development
if (__DEV__) {
  // Shake device or Cmd+D to access dev menu
  // Enable "Show Perf Monitor"
}

Common Memory Leaks

Uncleared subscriptions and listeners:

useEffect(() => {
  const subscription = eventEmitter.addListener('event', handler);

  // Always clean up
  return () => subscription.remove();
}, []);

Retained closures:

// Problem: the entire component scope is retained
useEffect(() => {
  const interval = setInterval(() => {
    // This closure retains references to everything in scope
    fetchData();
  }, 5000);

  return () => clearInterval(interval);
}, []);

Image cache growth:

// Periodically clear the image cache
FastImage.clearMemoryCache();
// Only clear disk cache when necessary (e.g., on logout)
FastImage.clearDiskCache();

Startup Time Optimisation

Reduce Bundle Size

Analyse your JavaScript bundle to find large dependencies:

npx react-native-bundle-visualizer

Replace large libraries with smaller alternatives where possible. For example, use date-fns instead of moment.js (moment adds roughly 300KB to your bundle).

Defer Non-Critical Work

Do not initialise everything at startup. Defer analytics, background sync, and non-visible features:

const App = () => {
  useEffect(() => {
    // Defer non-critical initialisation
    InteractionManager.runAfterInteractions(() => {
      initAnalytics();
      setupBackgroundSync();
      prefetchData();
    });
  }, []);

  return <AppNavigator />;
};

Inline Requires

For large modules, use inline requires to defer loading until the module is actually needed:

// Instead of top-level import
// import HeavyModule from './HeavyModule';

// Use inline require
const getHeavyModule = () => require('./HeavyModule').default;

// Only loaded when called
const handlePress = () => {
  const HeavyModule = getHeavyModule();
  HeavyModule.doSomething();
};

Measuring Performance

You cannot optimise what you do not measure. Use these tools:

  • Flipper: Meta’s desktop debugging tool with React Native plugins for performance profiling
  • React DevTools Profiler: Identifies unnecessary re-renders
  • Systrace (Android): Low-level performance tracing
  • Instruments (iOS): Apple’s profiling tools work with React Native apps
  • react-native-performance: Library for measuring component render times

Track these metrics in production:

  • Time to Interactive (TTI)
  • Frame rate during scrolling (target: 60fps)
  • JS thread frame drops
  • Memory usage over time

Conclusion

React Native performance is not magic. It is the result of understanding the framework’s architecture and applying targeted optimisations. Start with rendering (prevent unnecessary re-renders), move to lists (optimise FlatList), then address images, navigation, and startup time.

The techniques in this guide can transform a sluggish React Native app into one that feels native. The key is measuring before and after each change to ensure your optimisations have the intended effect.

If your React Native app needs a performance overhaul, get in touch with eawesome. We help Australian businesses build React Native apps that perform on par with native applications.