React Native Advanced: Performance, Native Code and Releasing
Performance and Rendering
In this lesson you will
- Explain the 16.67 ms frame budget and the difference between the JavaScript and UI threads
- Reduce needless re-renders with memo, stable props and the React Compiler
- Tune FlatList, images and console output, and find slow spots with React Native DevTools
Introduction
An app that feels slow gets deleted. People notice a stuttering scroll or a button that waits before it responds, even when they cannot say why. This lesson explains what "fast" means on a phone and gives you the tools to measure and improve it. It assumes you have built a few screens already.
The frame budget
Phones refresh the screen at least 60 times a second. The official docs put it this way: "iOS and Android devices display at least 60 frames per second, which gives you and the UI system at most 16.67ms to do all of the work needed to generate the static image (frame) that the user will see on the screen." If the work for a frame takes longer, a frame is dropped and the user sees a hitch.
Two threads share that work:
- The JavaScript thread runs your React code: state changes, rendering, your business logic, network handling.
- The UI thread (the main thread) draws the native views and handles scrolling and touches.
Native scrolling stays smooth even when the JavaScript thread is busy, because it runs on the UI thread. But anything that needs JavaScript, such as responding to a tap with a state change, waits when the JavaScript thread is stuck. So the main rule of performance is: keep the JavaScript thread free.
Quick quiz
What happens if the work for one frame takes longer than about 16.67 ms?
Measure in a release build
Never judge performance in development mode. The docs are blunt: "JavaScript thread performance suffers greatly when running in dev mode" and "Always make sure to test performance in release builds." Development builds add checks and logging that make the app slower than it will be for your users.
Before changing code to be faster, measure that it is slow. Guessing wastes time and often makes code harder to read for no gain.
React Native DevTools: find the slow part
Expo recommends React Native DevTools for debugging JavaScript. With the app running, press j in the terminal where Expo started. It has panels you will use for performance:
- React Components: inspect the component tree and props.
- React Profiler: record a session and see which components rendered, why, and how long each took. The docs describe it as the way to "understand the timing of component renders and React commits."
- Performance (since React Native 0.83): record where JavaScript time goes.
- Network (since 0.83): see every request your app makes.
Record a profile while you do the slow thing, such as scrolling a list. Components that render many times, or take a long time, are your suspects.
Needless re-renders
A component re-renders when its state changes or its parent renders. By default, when a parent re-renders, all its children re-render too, even when their props are exactly the same. For most screens that is fine. For a list with hundreds of rows, or an expensive row, it adds up.
React.memo wraps a component so it re-renders only when its props actually change:
React (TSX)
import { memo } from "react";
import { Pressable, StyleSheet, Text } from "react-native";
type Props = { id: string; title: string; onSelect: (id: string) => void };
const Row = memo(function Row({ id, title, onSelect }: Props) {
return (
<Pressable style={styles.row} onPress={() => onSelect(id)}>
<Text>{title}</Text>
</Pressable>
);
});
export default Row;
const styles = StyleSheet.create({
row: { padding: 16 },
});memo only helps if the props really stay the same. A function created inside the parent on every render is a new function each time, so the props differ and memo does nothing. Wrap such functions in useCallback, which keeps the same function between renders:
React (TSX)
import { useCallback, useState } from "react";
import { FlatList } from "react-native";
import Row from "./Row";
type Item = { id: string; title: string };
export default function Screen({ items }: { items: Item[] }) {
const [selected, setSelected] = useState<string | null>(null);
const onSelect = useCallback((id: string) => setSelected(id), []);
return (
<FlatList
data={items}
extraData={selected}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Row id={item.id} title={item.title} onSelect={onSelect} />}
/>
);
}Now onSelect is the same function every time, so a row re-renders only when its own id or title change.
Let the compiler do it
Writing memo and useCallback everywhere is tedious. The React Compiler adds this memoization automatically at build time. In Expo you turn it on in the app configuration:
JSON
{
"expo": {
"experiments": {
"reactCompiler": true
}
}
}From Expo SDK 54 onwards the build tool is set up for you. After the change, restart the dev server with npx expo start --clear. The compiler only works on your code, expects components that follow the rules of React, and does not optimise class components. You can opt a file out with the "use no memo" directive if it misbehaves. Even with the compiler, understanding what memo does tells you why it works.
Quick quiz
A memo-wrapped row still re-renders every time its parent does. What is the most likely cause?
Long lists
You met FlatList earlier. These settings help when lists are big or rows are heavy:
getItemLayout: if every row has a known height, tell FlatList so it can skip measuring. The docs call it "a great performance boost for lists of several hundred items."initialNumToRender: how many rows to draw first. It should fill the screen "but not much more."- Keep
renderItemcheap and the row componentmemo-ised, as above. - Do not put a long list inside a
ScrollView: that builds everything at once and defeats the point of a virtual list.
React (TSX)
import { FlatList, StyleSheet, Text } from "react-native";
const ROW_HEIGHT = 56;
const DATA = Array.from({ length: 2000 }, (_, i) => ({ id: String(i), label: `Item ${i}` }));
export default function BigList() {
return (
<FlatList
data={DATA}
keyExtractor={(item) => item.id}
getItemLayout={(_, index) => ({ length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index })}
initialNumToRender={12}
renderItem={({ item }) => <Text style={styles.row}>{item.label}</Text>}
/>
);
}
const styles = StyleSheet.create({
row: { height: ROW_HEIGHT, lineHeight: ROW_HEIGHT, paddingHorizontal: 16 },
});For very large or complex lists, the React Native docs also point to FlashList, a drop-in alternative built for speed.
Images
Images are often the heaviest thing on a screen:
- Do not download huge pictures for small spots. A 4000 pixel photo shown at 80 pixels still has to be decoded in full. Ask your server for a size that matches how it is shown.
- Use the
expo-imagelibrary instead of the built-inImagefor lists and galleries. It caches pictures to disk and memory and can show a placeholder while loading. - To animate an image's size, animate a scale transform, not its width and height. The docs warn that on iPhone each width or height change re-crops and rescales the picture, which "can be very expensive."
React (TSX)
import { Image } from "expo-image";
import { StyleSheet } from "react-native";
export default function Avatar({ uri }: { uri: string }) {
return <Image source={{ uri }} style={styles.avatar} contentFit="cover" transition={150} />;
}
const styles = StyleSheet.create({
avatar: { width: 64, height: 64, borderRadius: 32 },
});Other common slowdowns
console.login production. The docs note that in a bundled app these statements "can cause a big bottleneck in the JavaScript thread." Remove them from release builds, for example with thebabel-plugin-transform-remove-consoleplugin.- Heavy work in the JavaScript thread during an animation or scroll, such as parsing a huge JSON file. Move work out of the way of the interaction, or do less of it.
- State kept too high. If a value that changes often, such as the text in an input, lives in a top-level component, the whole screen re-renders on every keystroke. Keep state as close as possible to the component that needs it.
Try it yourself
Make a list of 1,000 rows where each row logs when it renders. Tap one row to select it and see how many rows render. Then add memo, a stable onSelect with useCallback, and compare the logs. Record the same interaction in the React Profiler. Finally, test the app in a release build and notice the difference from development mode.
Summary
- A frame has about 16.67 ms. Keep the JavaScript thread free, measure in release builds, and use React Native DevTools (press j) to find slow components before changing code.
- Reduce needless re-renders with
memoand stable props, or let the React Compiler do it automatically. - Tune long lists with
getItemLayoutandinitialNumToRender, useexpo-imageand right-sized pictures, and removeconsole.logfrom release builds.
Lesson complete
+20 XP
Nice work. Keep going while it is fresh.