useMemo and code splitting still apply in React Native, but they're not where most jank comes from. Most dropped frames trace back to the bridge (or JSI on the new architecture), the list you're rendering, or work running on the JS thread that should be on the UI thread. Profile on a real device, not the simulator, before changing anything.
The simulator has no thermal throttling, no bridge contention from other apps, and a different GPU path. A screen that's smooth in the iOS Simulator can drop frames on a mid-range Android phone. Use the in-app Perf Monitor (shake gesture -> "Show Perf Monitor") to watch JS and UI frame rates separately, then reach for Hermes debugger for a proper flame graph.
# Enable the Hermes engine sampling profiler
# Then pull the trace and open it in Chrome DevTools' Performance tab
npx react-native profile-hermes ./profilesIf both JS and UI thread stay near 60fps in the Perf Monitor during the interaction you care about, stop. You don't have a performance problem yet.
On the old architecture, every prop, every event, and every native module call gets serialized to JSON and passed across the bridge asynchronously. That's fine for occasional calls, expensive for anything high-frequency: scroll events, gesture updates, layout measurements fired on every frame.
// Fires a bridge crossing on every scroll frame
<ScrollView onScroll={(e) => setOffset(e.nativeEvent.contentOffset.y)} />;
// useNativeDriver keeps the animation on the UI thread,
// no bridge crossing per frame
Animated.timing(translateY, {
toValue: -100,
useNativeDriver: true,
}).start();If the app is on the new architecture (Fabric + TurboModules), JSI replaces the async bridge with direct, synchronous JS-to-native calls, which removes most of this class of problem. Check newArchEnabled in your Podfile/gradle config before assuming bridge overhead is the cause.
FlatList virtualizes, but it's easy to defeat that with the wrong props:
<FlatList
data={items}
keyExtractor={(item) => item.id} // stable key, not index
renderItem={renderItem} // defined outside render, not inline
getItemLayout={getItemLayout} // skip measurement if row height is fixed
removeClippedSubviews // unmount off-screen native views (Android especially)
windowSize={5}
maxToRenderPerBatch={10}
/>An inline renderItem={(item) => ...} creates a new function every render, which defeats memoization on every row. Pull it out, wrap the row component in React.memo, and give FlatList a stable keyExtractor.
A 3000×2000 photo displayed in a 100×100 thumbnail still decodes at full resolution unless you tell the platform otherwise. This is a bigger real-world cost than most JS-side optimization on image-heavy screens.
// Resize server-side or request a sized variant, don't ship the original
<Image
source={{ uri: `${imageUrl}?w=200&h=200` }}
style={{ width: 100, height: 100 }}
/>react-native-fast-image (or expo-image) adds disk caching and downsampling that the built-in Image component doesn't do by default, worth it for feeds, grids, or anything scrolling through many remote images.
Navigation transitions and gesture-driven animations run on the JS thread by default. Firing a heavy computation, a large state update, or a big list re-render mid-transition is what causes the stutter you feel when opening a screen.
import { InteractionManager } from "react-native";
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
loadHeavyData();
});
return () => task.cancel();
}, []);This defers the work until after the transition animation finishes, not forever, just off the critical frame path.
| Symptom | Fix |
|---|---|
| Janky animation during scroll/gesture | useNativeDriver: true |
| High-frequency events feel laggy | Move to JSI / new architecture |
| List scroll drops frames | Stable keyExtractor, extract renderItem, getItemLayout |
| Screen stutters right after navigation | InteractionManager.runAfterInteractions |
| Image-heavy screen feels slow | Request sized images, use FastImage/expo-image |
| Simulator is smooth, device isn't | Profile on-device with Perf Monitor / Hermes profiler |
Same starting discipline as web: profile before you optimize. The difference is what you're profiling for, on web it's re-renders and bytes over the network, on React Native it's frames on two threads and what's crossing the bridge between them.