What you'll learn
- Profile and fix a slow list
- Explain the frame budget and the roles of the JavaScript and UI threads
- Measure in a release-like build on a slow device before changing code
- Apply keys, memoized rows, getItemLayout and windowing to a FlatList
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 threadThe thread where React Native components render and app logic runs; if it is busy, taps and JS animations freeze., where your components render and your logic runs,
- the UI (main) threadThe thread where native views are laid out and drawn; overloading it drops frames while scrolling., 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 ProfilerA tool that records which components rendered, how long they took and why. (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 FlashListA list component from Shopify that recycles row views and is often faster for very long lists. (
@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.
Key terms (6)
- JavaScript thread
- The thread where React Native components render and app logic runs; if it is busy, taps and JS animations freeze.
- UI (main) thread
- The thread where native views are laid out and drawn; overloading it drops frames while scrolling.
- React DevTools Profiler
- A tool that records which components rendered, how long they took and why.
- React.memo
- A wrapper that skips re-rendering a component when its props have not changed.
- getItemLayout
- A FlatList prop that gives fixed row sizes so the list can skip measuring rows.
- FlashList
- A list component from Shopify that recycles row views and is often faster for very long lists.