Measure frame rates and renders, then fix a slow list with keys, memoization and windowing.
Why it matters
Users notice slowness more on phones than anywhere else: a list that stutters while scrolling, a button that responds a beat late, an app that takes four seconds to open. Many users own older, cheaper Android devices, so an app that feels fine on a new iPhone can be painful for them. Performance work is a mid-level skill because it requires measuring first and fixing the right thing.
Frames and threads
Smooth motion means rendering a frame about every 16.7 ms at 60 frames per second (8.3 ms on 120 Hz screens). React Native work happens mainly on:
- the JavaScript thread, where your components render and your logic runs,
- the UI (main) thread, where native views are laid out and drawn.
If the JS thread is busy for 200 ms (a big re-render, a heavy calculation), taps and JS-driven animations freeze. If the UI thread is overloaded (huge view trees, big images), scrolling drops frames.
Measure first
- The Performance Monitor in the developer menu shows JS and UI frame rates live.
- React DevTools Profiler (press
jin the Expo CLI to open the debugger tools) records which components rendered and how long they took, and why. - Test in a release-like build: development mode is much slower. Run
npx expo start --no-dev --minifyor use a preview build. - Test on a low-end Android phone or emulator profile, not only your own device.
Write down a number before you change anything ("JS frame rate drops to 20 fps while scrolling 1,000 rows") so you can tell whether a fix helped.
Worked example: fixing a slow list
A screen shows 2,000 transactions. Scrolling stutters and the profiler shows every row re-rendering whenever the parent's state changes.
// Before
<FlatList
data={transactions}
renderItem={({ item }) => <Row item={item} onPress={() => open(item.id)} />}
/>
Fixes, in the order they usually matter:
- Stable keys: add
keyExtractor={(t) => t.id}so rows are not recreated. - Memoized rows: wrap the row in
React.memoand pass stable props. Creating a newonPressarrow for every row each render defeats memo, so pass the id and a stable callback:
const Row = React.memo(function Row({ item, onOpen }) {
return (
<Pressable onPress={() => onOpen(item.id)} style={styles.row}>
<Text>{item.title}</Text>
<Text>{formatMoney(item.amount)}</Text>
</Pressable>
);
});
const onOpen = useCallback((id) => router.push(`/tx/${id}`), []);
const renderItem = useCallback(({ item }) => <Row item={item} onOpen={onOpen} />, [onOpen]);
<FlatList
data={transactions}
keyExtractor={(t) => t.id}
renderItem={renderItem}
getItemLayout={(_, index) => ({ length: 64, offset: 64 * index, index })}
initialNumToRender={12}
windowSize={7}
/>
- Fixed row height: if every row is the same height,
getItemLayoutlets the list skip measuring. - Tune windowing:
initialNumToRenderandwindowSizecontrol how many rows are kept rendered; smaller means less work and more blank space if users fling fast. - Do less per row: pre-format values once (when data arrives) instead of in every render, and use correctly sized thumbnails instead of full-size images.
- Consider FlashList (
@shopify/flash-list), which recycles row views and is often faster for very long lists.
Then measure again with the same steps.
Other common wins
- Startup: load only what the first screen needs; defer heavy imports and work until after first render; keep the splash screen short.
- Images: serve thumbnails at display size;
expo-imageadds caching. - Animations: run them on the UI thread (
useNativeDriver: truewith Animated, or Reanimated) so a busy JS thread does not stutter them. - Re-renders: split contexts so a frequently changing value does not re-render the whole tree.
- Remove
console.login hot paths: logging is slow, especially in development.
Common mistakes
- Optimizing without measuring, or measuring in development mode only.
ScrollViewwithmapfor long lists.- Inline arrow functions and objects as props to memoized rows.
useMemoanduseCallbackeverywhere "just in case": they have a cost too. Use them where the profiler shows a problem.
Quick recap
- Aim for 60 fps: keep both the JS and UI threads under their frame budget.
- Measure with the Performance Monitor and React DevTools Profiler, in a release-like build, on a slow device.
- For lists: keys, memoized rows with stable props,
getItemLayout, windowing, lighter rows, or FlashList.
Try it
The Do tab walks you through building a deliberately slow list, profiling it and fixing it step by step.