MS

0
Skip to main content

React Native Performance Optimization — Techniques I Use in Every Production App

March 1, 2025 (1y ago)

Performance is the feature no one asks for but everyone notices when it's missing. A janky scroll, a slow startup, a laggy transition — users don't file bug reports for these things. They just delete your app.

I've spent over three years building React Native apps professionally. As the Mobile Lead at Truxo.ai, I've optimized apps that handle real-time logistics data on devices ranging from budget Android phones in rural India to the latest iPhones. The techniques in this post come from that experience — they're practical, tested, and ordered by impact.

If you're new to React Native, start with my React Native Expo production guide first. This post assumes you have a working app and want to make it faster.

Measuring Before Optimizing

The first rule of performance optimization: measure, don't guess. I've seen developers spend days optimizing a component that renders in 2ms while ignoring a 500ms network waterfall on the same screen.

React Native Performance Monitor

Enable the built-in performance monitor during development:

// In your dev setup
if (__DEV__) {
  // Shows FPS, JS thread, and UI thread metrics
  // Shake device → "Show Perf Monitor"
}

The key metrics to watch:

Flipper and React DevTools Profiler

Flipper with the React DevTools plugin is my primary profiling tool. The Profiler tab shows exactly which components re-render and why:

  1. Open Flipper → React DevTools → Profiler
  2. Click Record
  3. Interact with your app
  4. Stop recording
  5. Analyze the flamegraph

Look for components that re-render without their props changing. These are your optimization targets.

Real Device Testing Is Non-Negotiable

Never trust performance measurements on the iOS Simulator or Android Emulator. They run on your development machine's hardware, which is orders of magnitude faster than a real device. I test on:

The performance issues that matter are the ones visible on mid-range devices. If it's smooth on a Galaxy A34, it's smooth everywhere.

Hermes: The Single Biggest Performance Win

If you're running React Native 0.70+ and not using Hermes, you're leaving massive performance gains on the table. Hermes is Meta's JavaScript engine designed specifically for React Native.

What Hermes Gives You

Enabling Hermes

In a modern Expo or React Native CLI project, Hermes is enabled by default. Verify it:

// Check if Hermes is running
const isHermes = () => !!global.HermesInternal;
console.log("Hermes enabled:", isHermes());

If it's not enabled, in your app.json (Expo):

{
  "expo": {
    "jsEngine": "hermes"
  }
}

Or in react-native.config.js (bare workflow):

module.exports = {
  reactNativeVersion: "0.73",
  project: {
    android: { hermesEnabled: true },
    ios: { hermesEnabled: true }
  }
};

Real Numbers from My Apps

In the Truxo logistics app, enabling Hermes reduced:

These are real measurements on a Samsung Galaxy A52, which represents our typical driver's phone. At Truxo, the transportation management system I helped build (covered in building AI transportation management) runs on exactly these kinds of devices.

List Performance: FlashList Over FlatList

If your app displays lists (and almost every app does), replacing FlatList with FlashList from Shopify is the highest-impact change you can make.

Why FlatList Is Slow

FlatList creates and destroys cell components as you scroll. Each creation triggers a React component mount, layout calculation, and paint. On a fast scroll, this means dozens of component mounts per second, which overwhelms the JavaScript thread.

FlashList's Architecture

FlashList recycles cell components instead of destroying them. When a cell scrolls off screen, its component is reused for the next cell entering the screen. This eliminates the mount/unmount overhead entirely.

import { FlashList } from "@shopify/flash-list";
 
interface OrderItem {
  id: string;
  customerName: string;
  status: string;
  amount: number;
}
 
function OrderList({ orders }: { orders: OrderItem[] }) {
  return (
    <FlashList
      data={orders}
      renderItem={({ item }) => <OrderCard order={item} />}
      estimatedItemSize={80} // Critical: provide accurate estimate
      keyExtractor={(item) => item.id}
    />
  );
}

estimatedItemSize Matters

The estimatedItemSize prop is not optional — it's critical for performance. If your estimate is wildly wrong, FlashList will miscalculate the scroll area and create visual glitches.

To find the right value:

  1. Render your list with FlashList
  2. Check the console for a warning like "estimatedItemSize={X} is not close enough to the actual average item size of Y"
  3. Update your estimate to match

For lists with variable-height items, use the average:

<FlashList
  data={messages}
  renderItem={({ item }) => <MessageBubble message={item} />}
  estimatedItemSize={120} // Average of short (60px) and long (180px) messages
  getItemType={(item) => (item.hasImage ? "image" : "text")} // Helps recycling
/>

Real Impact

Switching from FlatList to FlashList in a 500-item order list:

Metric FlatList FlashList Improvement
Initial render 340ms 45ms 87% faster
Scroll FPS (fast scroll) 24 FPS 58 FPS 2.4x
Memory usage (500 items) 210MB 85MB 60% less
Blank cells during scroll Frequent Rare Dramatic

Preventing Unnecessary Re-renders

React Native re-renders are the most common performance issue I encounter. The fix is almost always one of three things: React.memo, useMemo, or useCallback.

Understanding When React Native Re-renders

A component re-renders when:

  1. Its state changes
  2. Its parent re-renders (even if the child's props haven't changed)
  3. A context it consumes changes

Point 2 is the silent killer. A state change in a parent causes every child to re-render, recursively.

React.memo: The First Line of Defense

Wrap components that receive the same props across parent re-renders:

interface OrderCardProps {
  order: Order;
  onPress: (id: string) => void;
}
 
const OrderCard = React.memo(function OrderCard({ order, onPress }: OrderCardProps) {
  return (
    <Pressable onPress={() => onPress(order.id)}>
      <Text>{order.customerName}</Text>
      <Text>{order.status}</Text>
      <Text>₹{order.amount}</Text>
    </Pressable>
  );
});

But React.memo alone isn't enough. If the parent creates a new onPress function every render, React.memo sees different props and re-renders anyway.

useCallback: Stabilize Function Props

function OrderList({ orders }: { orders: Order[] }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);
 
  // Without useCallback: new function every render → OrderCard re-renders
  // With useCallback: same function reference → OrderCard skips re-render
  const handlePress = useCallback((id: string) => {
    setSelectedId(id);
  }, []);
 
  return (
    <FlashList
      data={orders}
      renderItem={({ item }) => (
        <OrderCard order={item} onPress={handlePress} />
      )}
      estimatedItemSize={80}
    />
  );
}

useMemo: Expensive Computations

For derived data that's expensive to compute:

function OrderAnalytics({ orders }: { orders: Order[] }) {
  // Without useMemo: recalculates on every render
  // With useMemo: recalculates only when orders change
  const stats = useMemo(() => ({
    total: orders.reduce((sum, o) => sum + o.amount, 0),
    pending: orders.filter((o) => o.status === "pending").length,
    avgAmount: orders.reduce((sum, o) => sum + o.amount, 0) / orders.length,
    byStatus: Object.groupBy(orders, (o) => o.status),
  }), [orders]);
 
  return (
    <View>
      <Text>Total: ₹{stats.total}</Text>
      <Text>Pending: {stats.pending}</Text>
      <Text>Average: ₹{stats.avgAmount.toFixed(0)}</Text>
    </View>
  );
}

The Over-Optimization Trap

Don't memoize everything. React.memo has a cost — it must compare all props on every render. For components that always receive different props or are cheap to render, React.memo makes things slower.

My rule of thumb:

Image Optimization

Images are the biggest memory consumers in most React Native apps. A single unoptimized 4000x3000 photo uses ~48MB of memory when decoded. Load ten of them and you've used half a gigabyte.

expo-image: The Modern Choice

If you're using Expo (which you should be — see my Expo production guide), use expo-image instead of React Native's built-in Image:

import { Image } from "expo-image";
 
function Avatar({ uri, size = 48 }: { uri: string; size?: number }) {
  return (
    <Image
      source={{ uri }}
      style={{ width: size, height: size, borderRadius: size / 2 }}
      contentFit="cover"
      transition={200}
      placeholder={{ blurhash: "LEHV6nWB2yk8pyoJadR*.7kCMdnj" }}
      recyclingKey={uri}
      cachePolicy="memory-disk"
    />
  );
}

Key props:

Resize on Upload, Not on Display

Never send a 4000x3000 image to a mobile client that will display it at 300x300. Resize server-side or use an image CDN:

function getOptimizedImageUrl(originalUrl: string, width: number): string {
  // Using Cloudinary as an example
  return originalUrl.replace(
    "/upload/",
    `/upload/w_${width},f_auto,q_auto/`
  );
}
 
function ProductImage({ url }: { url: string }) {
  const { width } = useWindowDimensions();
  const imageWidth = width - 32; // Account for padding
 
  return (
    <Image
      source={{ uri: getOptimizedImageUrl(url, Math.round(imageWidth)) }}
      style={{ width: imageWidth, height: imageWidth * 0.75 }}
      contentFit="cover"
    />
  );
}

Preloading Critical Images

For images that must appear instantly (splash screen content, hero images):

import { Image } from "expo-image";
 
// Preload during app initialization
async function preloadCriticalImages() {
  await Image.prefetch([
    "https://cdn.example.com/hero-image.webp",
    "https://cdn.example.com/logo.webp",
  ]);
}

Bundle Size Optimization

A smaller bundle means faster downloads, faster installs, and faster startup. Here's how I keep bundles lean.

Analyze Your Bundle

Use react-native-bundle-visualizer to see what's taking up space:

npx react-native-bundle-visualizer

Common culprits:

Tree Shaking with Metro

Metro (React Native's bundler) supports tree shaking in recent versions. Enable it:

// metro.config.js
module.exports = {
  transformer: {
    experimentalImportSupport: true,
  },
  resolver: {
    unstable_enablePackageExports: true,
  },
};

Lazy Loading Screens

Don't load every screen's code at startup. Use React's lazy for screens that aren't immediately visible:

import { lazy, Suspense } from "react";
import { ActivityIndicator } from "react-native";
 
const SettingsScreen = lazy(() => import("./screens/SettingsScreen"));
const ProfileScreen = lazy(() => import("./screens/ProfileScreen"));
const AnalyticsScreen = lazy(() => import("./screens/AnalyticsScreen"));
 
function AppNavigator() {
  return (
    <Suspense fallback={<ActivityIndicator />}>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={HomeScreen} />
        <Stack.Screen name="Settings" component={SettingsScreen} />
        <Stack.Screen name="Profile" component={ProfileScreen} />
        <Stack.Screen name="Analytics" component={AnalyticsScreen} />
      </Stack.Navigator>
    </Suspense>
  );
}

Navigation Performance

React Navigation is the standard, but it needs tuning for performance.

Enable Native Stack

Use @react-navigation/native-stack instead of @react-navigation/stack. The native stack uses platform-native navigation controllers (UINavigationController on iOS, Fragment on Android), which means transitions run on the native thread and never block JavaScript.

import { createNativeStackNavigator } from "@react-navigation/native-stack";
 
const Stack = createNativeStackNavigator();
 
// Native stack — smooth 60fps transitions
function AppNavigator() {
  return (
    <Stack.Navigator screenOptions={{ animation: "slide_from_right" }}>
      <Stack.Screen name="Home" component={HomeScreen} />
      <Stack.Screen name="Detail" component={DetailScreen} />
    </Stack.Navigator>
  );
}

Screen Optimization with freezeOnBlur

Screens that are off-screen should stop re-rendering. React Navigation's freezeOnBlur (available via react-freeze) does this:

<Tab.Navigator
  screenOptions={{
    freezeOnBlur: true, // Pause rendering of off-screen tabs
  }}
>
  <Tab.Screen name="Home" component={HomeScreen} />
  <Tab.Screen name="Orders" component={OrdersScreen} />
  <Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>

This is especially impactful for tab navigators where all tabs are mounted simultaneously but only one is visible.

Animation Performance

Animations are where users most notice performance issues. A dropped frame during an animation is far more noticeable than a dropped frame during static content display.

Use Reanimated for Complex Animations

React Native's built-in Animated API runs animations on the JavaScript thread by default. For complex animations, use react-native-reanimated, which runs animations on the UI thread:

import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withTiming,
  interpolate,
} from "react-native-reanimated";
 
function AnimatedCard({ isExpanded }: { isExpanded: boolean }) {
  const height = useSharedValue(0);
 
  React.useEffect(() => {
    height.value = withSpring(isExpanded ? 200 : 80, {
      damping: 15,
      stiffness: 150,
    });
  }, [isExpanded]);
 
  const animatedStyle = useAnimatedStyle(() => ({
    height: height.value,
    opacity: interpolate(height.value, [80, 200], [0.8, 1]),
  }));
 
  return (
    <Animated.View style={[styles.card, animatedStyle]}>
      {/* Card content */}
    </Animated.View>
  );
}

The key difference: Reanimated animations run on the UI thread via a native worklet system. Even if the JavaScript thread is busy (processing a network response, running business logic), the animation stays smooth.

Gesture Handler for Touch Performance

Pair react-native-reanimated with react-native-gesture-handler for smooth gesture-driven animations:

import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
 
function SwipeableCard() {
  const translateX = useSharedValue(0);
 
  const panGesture = Gesture.Pan()
    .onUpdate((event) => {
      translateX.value = event.translationX;
    })
    .onEnd(() => {
      if (Math.abs(translateX.value) > 150) {
        // Swipe threshold reached — dismiss
        translateX.value = withSpring(translateX.value > 0 ? 500 : -500);
      } else {
        // Snap back
        translateX.value = withSpring(0);
      }
    });
 
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }],
  }));
 
  return (
    <GestureDetector gesture={panGesture}>
      <Animated.View style={[styles.card, animatedStyle]}>
        {/* Card content */}
      </Animated.View>
    </GestureDetector>
  );
}

This entire gesture + animation pipeline runs on the native thread. Zero JavaScript bridge overhead.

Startup Time Optimization

First impressions matter. If your app takes 4 seconds to load, you've already lost users.

Minimize Initial Screen Complexity

The first screen should render fast. Move expensive operations to after the initial paint:

function HomeScreen() {
  const [isReady, setIsReady] = useState(false);
 
  useEffect(() => {
    // Defer heavy initialization
    InteractionManager.runAfterInteractions(() => {
      // Load analytics, prefetch data, initialize SDKs
      initializeAnalytics();
      prefetchUserData();
      setIsReady(true);
    });
  }, []);
 
  return (
    <View>
      <Header /> {/* Renders immediately */}
      <QuickActions /> {/* Renders immediately */}
      {isReady ? <RecentOrders /> : <OrdersSkeleton />}
    </View>
  );
}

Splash Screen Strategy

Use expo-splash-screen to keep the splash screen visible until your app is genuinely ready:

import * as SplashScreen from "expo-splash-screen";
 
SplashScreen.preventAutoHideAsync();
 
function App() {
  const [appReady, setAppReady] = useState(false);
 
  useEffect(() => {
    async function prepare() {
      try {
        // Load fonts, fetch initial data, check auth
        await Promise.all([
          loadFonts(),
          fetchInitialData(),
          checkAuthStatus(),
        ]);
      } finally {
        setAppReady(true);
        await SplashScreen.hideAsync();
      }
    }
    prepare();
  }, []);
 
  if (!appReady) return null;
  return <AppNavigator />;
}

Memory Leak Prevention

Memory leaks cause apps to slow down over time and eventually crash. Here are the most common sources in React Native.

Clean Up Subscriptions

Every addEventListener, setInterval, WebSocket connection, and event emitter subscription must be cleaned up:

function LocationTracker() {
  useEffect(() => {
    const subscription = Location.watchPositionAsync(
      { accuracy: Location.Accuracy.High, distanceInterval: 10 },
      (location) => {
        updateDriverLocation(location.coords);
      }
    );
 
    return () => {
      subscription.then((sub) => sub.remove());
    };
  }, []);
}

Avoid Closures That Capture Large Objects

Be careful with closures in callbacks that capture large state objects:

// Bad: captures entire orders array in closure
function OrderScreen() {
  const [orders, setOrders] = useState<Order[]>([]);
 
  const handleWebSocket = useCallback(() => {
    ws.onmessage = (event) => {
      const newOrder = JSON.parse(event.data);
      setOrders([...orders, newOrder]); // Stale closure + captures old orders
    };
  }, [orders]); // Re-creates on every orders change
 
  // Good: use functional state update
  const handleWebSocket = useCallback(() => {
    ws.onmessage = (event) => {
      const newOrder = JSON.parse(event.data);
      setOrders((prev) => [...prev, newOrder]); // No closure over orders
    };
  }, []);
}

Performance Checklist

Here's the checklist I run through for every React Native app before release:

  1. Hermes enabled and verified
  2. FlashList replacing FlatList for all lists with more than 20 items
  3. Images optimized: correct size, WebP format, caching enabled
  4. React.memo on all list item components
  5. useCallback on all function props passed to memoized components
  6. Native stack navigator instead of JS stack
  7. freezeOnBlur on tab navigators
  8. Reanimated for all complex animations
  9. No console.log in production (use babel-plugin-transform-remove-console)
  10. Bundle analyzed and large dependencies replaced
  11. Startup time under 2 seconds on mid-range Android
  12. Memory usage stable over 30 minutes of use (no leaks)

Closing Thoughts

React Native performance optimization is not about knowing obscure tricks — it's about consistently applying a small set of proven techniques. Hermes, FlashList, proper memoization, image optimization, and native navigation cover 90% of performance issues.

The remaining 10% requires profiling, measuring, and understanding your specific app's bottleneck. There's no substitute for testing on real devices with real data.

If you're deciding between React Native and Flutter for your next project, I compared both frameworks in my React Native vs Flutter 2025 analysis. And for a complete guide to shipping React Native apps to production, check my Expo production guide.

For more articles on mobile development, full stack engineering, and building production software, visit my blog. I'm Manjodh Singh Saran — a senior software engineer and mobile lead based in Ludhiana, India, building high-performance mobile apps with React Native.

Related Articles

MS
Manjodh Singh Saran

Full Stack Developer · Ludhiana, India

Read more articles · View portfolio