Back to Blogs
May 14, 20264 min read

React Native Performance: What Actually Matters

Most React Native performance advice focuses on the wrong things. Here's what I've learned from building apps used by hundreds of thousands of users.

The Performance Myth

There's a persistent belief that React Native is inherently slow, and that JavaScript is the bottleneck. In practice, most performance problems I've encountered come from a different place: unnecessary renders, unoptimized lists, and work on the wrong thread.

If you profile your app and find the JS thread saturated, the problem is almost never "it's React Native" — it's "this code is doing too much."

FlatList Is Your Most Important Component

A ScrollView renders all its children at once. For a list with 50 items, that means 50 components mounted and measured before the user sees anything.

FlatList is virtualized — it only renders items visible on screen plus a small buffer. For any list longer than 10-15 items, use FlatList.

<FlatList
    data={transactions}
    keyExtractor={(item) => item.id}
    renderItem={({ item }) => <TransactionRow transaction={item} />}
    getItemLayout={(_, index) => ({
        length: ITEM_HEIGHT,
        offset: ITEM_HEIGHT * index,
        index,
    })}
    initialNumToRender={12}
    maxToRenderPerBatch={10}
    windowSize={5}
    removeClippedSubviews
/>

getItemLayout is critical if your items have a fixed height. It lets FlatList skip measuring items during scroll, which eliminates the main source of FlatList jank.

Memo Carefully, Not Everywhere

React.memo prevents re-renders when props haven't changed. But it has a cost — React must compare the old and new props on every parent render. If your component is cheap to render, memo may cost more than it saves.

Use memo when:

  • The component is expensive to render (complex layout, image processing)
  • It re-renders frequently from a high-level parent
  • Its props are stable (primitives or stable object references)
const TransactionRow = React.memo(
    function TransactionRow({ transaction }: { transaction: Transaction }) {
        return (
            <View style={styles.row}>
                <Text style={styles.title}>{transaction.description}</Text>
                <Text style={styles.amount}>{formatAmount(transaction.amount)}</Text>
            </View>
        )
    },
    (prev, next) => prev.transaction.id === next.transaction.id
        && prev.transaction.amount === next.transaction.amount
)

The custom comparator compares only the fields that affect rendering, not the whole object.

useCallback and useMemo Have the Same Cost Warning

Every useCallback allocates a new function on every render, checks the dependencies, and — if they haven't changed — returns the cached one. This is cheaper than re-creating the function, but not free.

The most common place it matters:

function TransactionList({ accountId }: { accountId: string }) {
    // Without useCallback, this creates a new function every render,
    // causing all memoized TransactionRow items to re-render
    const handlePress = useCallback((id: string) => {
        navigation.navigate('TransactionDetail', { id })
    }, [navigation])

    return (
        <FlatList
            data={transactions}
            renderItem={({ item }) => (
                <TransactionRow
                    transaction={item}
                    onPress={handlePress}
                />
            )}
        />
    )
}

Without useCallback, handlePress is a new function reference on every render, defeating the React.memo on TransactionRow.

The JS Thread and the UI Thread

React Native has two threads relevant to animation:

  • JS thread: runs your JavaScript, including React rendering
  • UI thread: handles native layout, touch, and the Animated API

Most jank comes from heavy work blocking the JS thread during scroll. The fix is to move animations off the JS thread entirely using useNativeDriver: true.

const opacity = useRef(new Animated.Value(0)).current

useEffect(() => {
    Animated.timing(opacity, {
        toValue: 1,
        duration: 300,
        useNativeDriver: true, // Runs on UI thread — won't be blocked by JS work
    }).start()
}, [])

useNativeDriver only works for transform and opacity animations. For layout animations, use LayoutAnimation with care — it's synchronous and can cause layout thrash if overused.

Images Are the Biggest Offender

Unoptimized images are the most common cause of memory pressure and jank in production React Native apps:

  • Load the right size — don't load a 2000×2000px image for a 100×100pt avatar
  • Use resizeMode="cover" or "contain" to prevent layout recalculation
  • Prefer FastImage (react-native-fast-image) over the built-in Image for HTTP images — it caches aggressively and reuses connections
  • For large image lists, set explicit width and height on every image so the layout engine doesn't have to measure
<FastImage
    source={{ uri: avatarUrl, priority: FastImage.priority.normal }}
    style={{ width: 48, height: 48, borderRadius: 24 }}
    resizeMode={FastImage.resizeMode.cover}
/>

Profile Before You Optimize

The React Native Debugger and Flipper both expose the component render profiler. Before you add memo to every component or rewrite a list, spend 10 minutes with the profiler to find the actual bottleneck.

In my experience, 80% of performance issues in production React Native apps come from four things: FlatList without getItemLayout, inline function creation in render, images without explicit dimensions, and heavy synchronous work in useEffect that runs on mount. Fix those first.

Written by

Zikri Akmal Santoso

Software Engineer

More Articles