Debugging Memory Growth in React Native FlatLists: windowSize, removeClippedSubviews, and the keyExtractor Trap
A FlatList screen starts smooth but memory keeps climbing the longer you scroll, until the app slows down or gets killed. It's almost never a framework bug — here's the three specific configuration traps causing it and the definitive fix.
The Problem
A React Native screen with a long scrollable list starts out smooth, but after scrolling through a few hundred items — or leaving the screen open for a while with new data streaming in — memory usage climbs steadily and never comes back down. Eventually the app slows to a crawl or gets killed by the OS for excessive memory use, even though the list only ever shows a handful of rows on screen at once.
The usual suspects get blamed first: "FlatList is slow," "React Native has a memory leak." In practice, the actual cause is almost always one of three very specific, very fixable configuration mistakes.
Why It Happens
An unstable or missing keyExtractor
If keyExtractor returns the array index (the default when no keyExtractor is given and items have no key/id), React can't reliably tell that item 5 in a re-rendered list is "the same" item 5 from before — especially once items are inserted, removed, or reordered. Instead of reusing the existing native view, it tears one down and creates a new one, and the old one isn't always released promptly.
windowSize left at a value too large for the row's actual weight
windowSize controls how many screens' worth of content are kept rendered above and below the visible area (default is 21 — about 10 screens on each side). That default is fine for simple rows, but for rows with images, nested lists, or animations, it means dozens of expensive components are mounted and held in memory at once, far beyond what's ever visible.
removeClippedSubviews interacting badly with absolutely-positioned children
removeClippedSubviews is meant to detach off-screen native views to save memory, but on Android in particular it can miscalculate the bounds of rows containing absolutely-positioned or overlapping children, either failing to clip them (no memory saved) or clipping visible content (rows flicker or vanish while scrolling).
The Fix
1. Use a real, stable, unique key
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={renderItem}
/>
Never fall back to the array index for data that can be inserted, removed, or reordered. If your data genuinely has no unique id, generate and persist one when the item is created, not at render time.
2. Tune windowSize and initialNumToRender to the row's actual cost
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={renderItem}
windowSize={5}
initialNumToRender={8}
maxToRenderPerBatch={8}
/>
For rows with images or complex layouts, a much smaller windowSize (5–7 instead of the default 21) keeps far fewer off-screen rows mounted, with no visible difference in scroll smoothness for most content. Pair it with initialNumToRender sized to roughly one screen, not the whole dataset.
3. Memoize renderItem and row components
const renderItem = useCallback(({ item }) => <Row item={item} />, []);
const Row = React.memo(function Row({ item }) { /* ... */ });
Without memoization, every re-render of the list re-creates every row component from scratch, which defeats FlatList's own view-recycling and multiplies the effect of any of the issues above.
4. Test removeClippedSubviews in isolation before trusting it
Add it back one screen at a time and verify on a real Android device (not just the emulator) that rows aren't flickering or disappearing mid-scroll. If a specific row layout misbehaves with it enabled, it's usually cheaper to fix that row's structure (avoid overlapping absolutely-positioned children) than to disable the optimization entirely.
5. For very large or frequently-updated lists, consider FlashList
Shopify's FlashList is a drop-in-ish replacement for FlatList built around cell recycling from the start, and avoids several of these tuning traps by default. It's worth the migration for screens where the dataset genuinely runs into the thousands of items.
Why This Works
All three fixes attack the same underlying issue: FlatList's performance model depends on being able to recycle native views instead of creating new ones. A bad key breaks recycling identity, an oversized window keeps far more views alive than necessary, and unmemoized rows force full re-creation on every render. Fix the identity, shrink the window, and stop the re-creation, and memory stays flat no matter how long the list stays open.
Conclusion
Steadily climbing memory in a React Native list is rarely a framework bug — it's almost always a missing stable key, a windowSize left at its default for heavy rows, or renderItem re-creating components on every render. Fix those three together and the list stays fast and flat in memory even after scrolling through thousands of rows.
