Your React Native Feed Doesn't Need Every Row Rendered: FlatList Windowing Done Right
ScrollView renders every row eagerly; FlatList renders only what's near the viewport. Here's how to tune windowing, guard onEndReached pagination, and use getItemLayout for free performance.
06 Sept 2025, 17:15 UTC

If your React Native screen renders a few hundred feed items inside a ScrollView, you've already met the problem: the screen takes a beat too long to appear, memory climbs, and scrolling stutters on mid-range Android devices. The fix isn't a faster phone or a native rewrite — it's letting the list render only what's near the viewport. That's what FlatList does by default, and with a handful of props you can tune it from "better" to "smooth." This post walks through the practical decisions: when windowing matters, how to paginate without double-fetching, and when fixed row heights buy you free performance.
Why ScrollView falls over on long lists
ScrollView renders all of its children eagerly. Two hundred rows means two hundred row components mounted at once, each holding state, images, and layout. Mount time grows linearly, and memory follows. FlatList virtualizes: it keeps only the rows near the visible window mounted and unmounts the rest as you scroll. The trade is that rows appearing at the edge of the window must render fast enough to avoid visible blank cells — which is exactly what the tuning props control.
The props that actually matter
Three props shape how aggressively FlatList mounts and unmounts rows:
initialNumToRender— how many rows render on the first pass. Set it to roughly one screenful; too high and you're back to slow initial mount.windowSize— measured in screen-heights of content kept mounted around the viewport (default is 21). Lower it to save memory on heavy rows; raise it if users see blank areas during fast flings.maxToRenderPerBatch— caps rows rendered per batch while scrolling, smoothing out JS-thread spikes.
One more prop pays off disproportionately when your rows have a fixed height: getItemLayout. It tells FlatList each row's exact offset and length up front, so the list skips measuring rows and scrollToIndex lands precisely without layout passes.
A worked example: a paginated feed with fixed-height rows
Say each feed card is exactly 96dp tall and you load 25 items per page. The component below combines windowing, fixed layout, and guarded pagination:
const ITEM_HEIGHT = 96;
function FeedScreen() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const loadingRef = useRef(false);
const [hasMore, setHasMore] = useState(true);
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
try {
const next = await fetchFeedPage(page); // your API call
setItems(prev => [...prev, ...next.items]);
setHasMore(next.items.length > 0);
setPage(p => p + 1);
} finally {
loadingRef.current = false;
}
}, [page, hasMore]);
return (
<FlatList
data={items}
keyExtractor={item => item.id}
renderItem={({ item }) => <FeedCard item={item} />}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={11}
getItemLayout={(_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
/>
);
}Two details here are easy to get wrong. First, the loadingRef guard: onEndReached can fire repeatedly — including on initial render when the list is shorter than one screen — so without a flag you'll issue duplicate fetches for the same page. A ref (not state) avoids stale-closure races between rapid triggers. Second, keyExtractor uses a stable ID. If you key by array index and later prepend items, every key shifts, React remounts every row, and your windowing gains evaporate in a flicker of lost component state.
To verify the pagination guard, add a counter or log inside fetchFeedPage and scroll fast to the bottom: each page should be requested exactly once. To verify windowing itself, render the same 500 items in a ScrollView and in this FlatList, and compare mount time and memory in React DevTools, Flipper, or Xcode Instruments — the difference is usually obvious without precise benchmarking.
Trade-offs and when to look elsewhere
Windowing isn't free. Rows that scroll out unmount, so any local state inside a row (an expanded comment thread, a text input) is lost unless you lift it into your data model. Aggressively small windowSize values cause blank cells during fast scrolls, especially with image-heavy rows that render slowly. And prop defaults vary across React Native versions, so check the FlatList/VirtualizedList docs for the version you're actually shipping before tuning.
If you have thousands of heterogeneous rows and still see blank cells, look at cell-recycling alternatives such as Shopify's FlashList, which reuses row views instead of unmounting them. It isn't a drop-in replacement — it asks for an estimatedItemSize and has its own API constraints — but for very large feeds it can be the difference between acceptable and janky.
Where to start
If you take one thing from this: any list that can grow unbounded belongs in FlatList (or a virtualized alternative), never ScrollView. Then, in order of effort: give it a stable keyExtractor, guard onEndReached with a loading flag, and add getItemLayout if your rows are fixed-height. Only then reach for windowSize and batch tuning — and measure before and after, because the right values depend on how heavy your rows actually are.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.