Fast scrolling in a React Native app depends on more than turning on virtualization. This guide provides a reusable FlatList performance checklist for feeds, search results, catalogs, and other long lists, with practical patterns for row rendering, pagination, images, refresh behavior, profiling, and regression testing.
Overview
FlatList is usually the right starting point for a large or growing collection because it renders only a window of items instead of mounting the entire dataset at once. That does not make every list fast automatically. A list can still stutter when each row is expensive, data changes too frequently, images are oversized, or pagination triggers repeated renders.
Begin with a simple mental model: every visible row consumes rendering time, layout work, memory, and often network or image-decoding resources. Performance work is therefore a process of reducing unnecessary work rather than applying one universal prop. Measure the list on representative devices, with realistic item content and data volume, before and after each meaningful change.
A dependable React Native FlatList implementation usually has these characteristics:
- Stable keys and predictable item identity.
- Rows that avoid rerendering when their relevant data has not changed.
- Images sized for their display area and loaded with sensible placeholders.
- Pagination that prevents duplicate requests and handles retries.
- Explicit loading, empty, error, and refreshing states.
- Performance settings tuned from measurements rather than copied blindly.
If the list is part of a navigational flow, keep screen-level concerns separate from row rendering. For routing patterns, see the React Native navigation guide. If authentication controls which feed a user can access, the Expo Router authentication guide provides the related structure.
Checklist by scenario
For a basic list
Start with a small, explicit component. Use a stable identifier from the data source rather than the array index whenever items can be inserted, removed, or reordered.
const renderItem = ({ item }: { item: Product }) => (
<ProductRow product={item} />
);
<FlatList
data={products}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={products.length === 0 ? styles.emptyList : undefined}
ListEmptyComponent={<EmptyState />}
/>
- Keep
renderItemstable with a module-level function oruseCallbackwhen appropriate. - Use
keyExtractorwith a unique, stable value. - Move row logic into a focused component instead of placing a large render tree inline.
- Keep list data normalized and avoid creating a new transformed array on every render unless the transformation is memoized.
For rows that update frequently
Wrap a row in React.memo when its props are stable and the component can safely skip unrelated updates. Memoization is not automatically beneficial: if the parent creates new object, array, or function props every render, the row may still render. Pass small, stable props where possible.
const ProductRow = React.memo(function ProductRow({
id,
title,
selected,
onPress,
}: ProductRowProps) {
return (
<Pressable onPress={() => onPress(id)}>
<Text>{title}</Text>
{selected ? <Text>Selected</Text> : null}
</Pressable>
);
});
Check callback dependencies carefully. A callback that changes because it captures an entire screen state object can invalidate every row. Prefer passing an identifier and a stable action where that matches the component design.
For fixed-height rows
If every item has the same known height, getItemLayout can avoid repeated measurement work and make scrolling to an index more predictable.
const ROW_HEIGHT = 72;
const getItemLayout = (_data: Product[] | null | undefined, index: number) => ({
length: ROW_HEIGHT,
offset: ROW_HEIGHT * index,
index,
});
Only use this when the value matches the actual rendered layout, including separators or other fixed spacing. An incorrect layout calculation can cause jumps, clipped rows, or inaccurate scroll positions. For variable-height content, do not guess a single height simply to enable this optimization.
For infinite scroll and pagination
Use onEndReached as a signal to request the next page, not as proof that a request should always begin. Guard against duplicate calls, track whether another page exists, and distinguish initial loading from loading the next page.
const loadNextPage = async () => {
if (loadingMore || !hasNextPage) return;
setLoadingMore(true);
try {
const nextPage = await fetchProducts({ cursor: nextCursor });
setProducts((current) => [...current, ...nextPage.items]);
setNextCursor(nextPage.nextCursor);
setHasNextPage(Boolean(nextPage.nextCursor));
} finally {
setLoadingMore(false);
}
};
<FlatList
data={products}
onEndReached={loadNextPage}
onEndReachedThreshold={0.5}
ListFooterComponent={loadingMore ? <ActivityIndicator /> : null}
/>
The threshold is an experience setting, not a performance fix. Choose it based on request latency, row height, and how much content a user can consume before the next page arrives. Make the fetch path idempotent or otherwise protected against repeated calls.
For feeds with images
- Request or select an image size close to the rendered dimensions.
- Reserve the image area so rows do not repeatedly change height as images load.
- Use placeholders for slow or missing images.
- Test cached, uncached, slow-network, and error states.
- Avoid mounting several large decorative images in every row.
Image behavior can dominate perceived list performance even when JavaScript rendering is efficient. Treat image loading as part of the list budget, not as a separate concern.
For pull-to-refresh, empty, and error states
Use refreshing and onRefresh for a visible refresh operation, but do not replace existing content with a full-screen spinner during every refresh. Preserve the current list when possible, show a footer indicator for pagination, and provide a retry action for recoverable errors.
Test the zero-item state separately from the failed-request state. An empty catalog, a network failure, and a filtered-out result may need different messages and actions. Keep empty-state layouts inside the list when you want them to participate in scrolling and safe-area behavior.
What to double-check
Windowing settings
Props such as initialNumToRender, maxToRenderPerBatch, updateCellsBatchingPeriod, and windowSize trade memory, fill rate, and responsiveness against one another. Increasing every value can make blank areas less likely but can also increase work during scrolling. Change one setting at a time and test on a slower representative device.
Data identity
When appending a page, preserve existing item references where their content has not changed. Rebuilding every object can make memoized rows appear changed. Also check selectors and state subscriptions: a screen subscribed to broad state may rerender the list for unrelated updates.
Interaction cost
Profile while flinging, tapping, refreshing, opening a menu, and receiving new data. A list that scrolls well in isolation may still feel slow when a bottom sheet, animated header, navigation transition, or keyboard is active. Review the broader React Native performance optimization guide when the bottleneck is not limited to the list.
Regression checks
Record a small benchmark before changing the implementation: device or simulator, build mode, item count, row type, image state, and the interaction tested. After a dependency or feature change, repeat the same scenario. A consistent test setup is more useful than an isolated impression from a development build.
Common mistakes
- Using array indexes as keys: insertions and deletions can associate the wrong state with a row.
- Putting expensive work in render: filtering, sorting, parsing, or formatting large values for every row increases scroll-time work. Precompute or memoize where appropriate.
- Memoizing everything: shallow comparison has a cost, and unstable props defeat it. Confirm that memoization reduces actual renders.
- Adding nested virtualized lists: a vertical
FlatListinside another vertical virtualized list can create measurement and scrolling problems. Restructure the screen or use list headers and footers where suitable. - Ignoring duplicate pagination calls: rapid scrolling, short datasets, and rerenders can trigger the end callback more than once.
- Optimizing only in development: development tooling can change timing. Compare release-like builds while still using debuggable profiling methods.
- Rendering every control in every row: menus, complex animations, and hidden interactive elements still add mounting and layout cost. Render them only when needed.
Accessibility should remain part of the optimization work. Smaller text, reduced controls, or removed labels are not valid performance fixes. Check accessible names, focus behavior, touch targets, and dynamic content after changing a row. The React Native accessibility checklist is useful for this review.
When to revisit
Revisit this checklist before a major release, before a seasonal traffic or catalog change, and whenever a list gains a new row feature, image treatment, animation, filter, or pagination path. Also repeat the review after upgrading React Native, Expo, a navigation package, an image library, or a state-management dependency; rendering behavior and defaults can change even when the list code does not.
Use this practical sequence:
- Choose one representative list and record its current interaction baseline.
- Test initial load, rapid scrolling, refresh, pagination, empty results, errors, and image failures.
- Use profiling tools to identify whether the issue is rendering, layout, JavaScript work, images, network latency, or memory pressure.
- Apply one focused change, then repeat the same scenarios on representative devices.
- Keep the simplest implementation that meets the measured target, and document any tuned FlatList props.
FlatList performance is an ongoing engineering check rather than a one-time configuration task. When row complexity, data volume, or user interaction changes, return to the evidence: stable keys, controlled renders, accurate layout assumptions, bounded pagination, efficient images, and repeatable measurements.