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:
- JS FPS: Should stay at 60. Drops below 30 are noticeable.
- UI FPS: Native thread performance. Drops here mean the native rendering pipeline is blocked.
- RAM usage: Watch for memory leaks, especially on navigation transitions.
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:
- Open Flipper → React DevTools → Profiler
- Click Record
- Interact with your app
- Stop recording
- 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:
- Mid-range Android: Samsung Galaxy A-series or equivalent (represents the majority of users in India and emerging markets)
- Older iPhone: iPhone SE or iPhone 11 (represents the lower end of iOS users)
- Latest flagships: For baseline comparison
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
- 50-80% faster startup time: Hermes compiles JavaScript to bytecode at build time, eliminating the need for runtime parsing and compilation.
- 30-40% lower memory usage: Hermes's garbage collector is optimized for mobile.
- Smaller app size: Bytecode is more compact than source JavaScript.
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:
- Cold start time: 3.2s → 1.4s (56% reduction)
- Memory usage at idle: 180MB → 112MB (38% reduction)
- TTI (Time to Interactive): 4.1s → 2.1s (49% reduction)
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:
- Render your list with
FlashList - Check the console for a warning like "estimatedItemSize={X} is not close enough to the actual average item size of Y"
- 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:
- Its state changes
- Its parent re-renders (even if the child's props haven't changed)
- 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:
- Memoize: List items, heavy components, components that rarely change
- Don't memoize: Leaf components (just text/icons), components that always receive new props, components that are already fast
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:
placeholderwith blurhash: Shows an instant blurred preview while the image loads. This eliminates the jarring pop-in effect.cachePolicy: "memory-disk": Cache in both memory (fast) and disk (persistent). Avoid re-downloading images the user has seen before.recyclingKey: In lists, this tellsexpo-imagewhen to reuse vs replace the image content during cell recycling.transition: Smooth fade-in when the image loads.
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-visualizerCommon culprits:
- Moment.js: Replace with
date-fnsordayjs(saves ~200KB) - Lodash (full): Use
lodash-eswith tree shaking or individual imports likelodash/debounce - Large icon libraries: Import only the icons you use, not the entire library
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:
- Hermes enabled and verified
- FlashList replacing FlatList for all lists with more than 20 items
- Images optimized: correct size, WebP format, caching enabled
- React.memo on all list item components
- useCallback on all function props passed to memoized components
- Native stack navigator instead of JS stack
- freezeOnBlur on tab navigators
- Reanimated for all complex animations
- No
console.login production (usebabel-plugin-transform-remove-console) - Bundle analyzed and large dependencies replaced
- Startup time under 2 seconds on mid-range Android
- 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.