React Native List Performance - ScrollView, FlatList, SectionList, FlashList
Your feed scrolls fine with 20 items. Ship 2,000 and the UI starts hitching, memory climbs, and "just wrap it in a ScrollView" becomes the bug report. Lists are where most React Native apps feel slow. Not because RN is slow - because the wrong list API (or the right one, misconfigured) forces the JS thread and the UI thread to do work they shouldn't.
This is the practical guide: which component to pick, how recycling actually works, and the knobs that matter in production.
TL;DR
| Component | Best for | Virtualizes? | Recycles views? | Scale |
|---|---|---|---|---|
ScrollView |
Short, fixed content | ❌ mounts all | ❌ | ~tens of items |
FlatList |
Long homogeneous lists | ✅ | ✅ (limited) | hundreds-low thousands |
SectionList |
Grouped lists (A-Z, dates) | ✅ | ✅ (limited) | hundreds-low thousands |
FlashList (@shopify/flash-list) |
Long lists that must feel native | ✅ | ✅ (aggressive) | thousands+ |
Rule of thumb: If every child mounts at once → not a list for large data. Use virtualization. Prefer FlashList for feeds, catalogs, chats, search results. Use FlatList/SectionList when you want zero extra deps or need sticky section headers with RN primitives. Use ScrollView only when the content is small and known.
What makes a list "slow"
A scrollable screen can be slow for three different reasons. Optimize the right one.
- Mount cost - Too many React trees created up front → blank screen, high JS heap, slow first paint.
- Frame cost (scroll jank) - Too much work per frame while scrolling → dropped frames, sticky fingers.
- Memory cost - Too many native views / images retained → OOM, background kills, iOS jetsam.
Virtualization attacks (1) and (3). Recycling + lighter rows attack (2). Image strategy and memoization finish the job.
ScrollView
[ item ][ item ][ item ] ... [ item ] ← all mounted
FlatList / SectionList / FlashList
viewport + overscan window
┌─────────────────────────┐
│recycled row cells │← only these exist in native UI
└─────────────────────────┘
↕ bind new data as you scroll
1. ScrollView - when it's correct (and when it's a footgun)
ScrollView renders every child immediately. No windowing. No recycling.
Use it when:
- Settings screens, forms, onboarding steps
- Content height is roughly one or two screens
- You need nested layout that isn't a data-driven list
- Item count is small and stable (say under 20-30 simple rows)
Don't use it when:
data.map(...)over API results of unknown length- Chat history, product grids, notification feeds
- Anything that grows with user data
Anti-pattern
// ❌ Looks fine in demos. Dies in production.
<ScrollView>
{products.map(item => (
<ProductCard key={item.id} item={item} />
))}
</ScrollView>
Why it hurts: All ProductCard components mount on open. All images may start loading. Native view hierarchy is huge. Scroll is smooth until it isn't - then memory and JS GC thrash.
Nested ScrollView + list = pain
// ❌ Avoid
<ScrollView>
<Header />
<FlatList data={items} ... /> {/* nested VirtualizedList warning / broken windowing */}
</ScrollView>
Prefer:
- One list with
ListHeaderComponent/ListFooterComponent - Or
FlashList's similar header/footer APIs - Or a single scroll surface (don't nest vertical scrollers)
⚠️ Warning: Nesting a vertical FlatList inside a vertical ScrollView breaks windowing. You'll often get the "VirtualizedLists should never be nested…" warning - and real jank.
2. FlatList - the default virtualized list
FlatList is a convenience wrapper over VirtualizedList. It only mounts items near the viewport (plus a render window), and reuses some native cells as you scroll.
Minimal solid setup
import { FlatList, Pressable, Text } from 'react-native';
import { memo, useCallback } from 'react';
const Row = memo(function Row({ item, onPress }) {
return (
<Pressable onPress={() => onPress(item.id)} style={styles.row}>
<Text>{item.title}</Text>
</Pressable>
);
});
function ProductList({ products, onOpen }) {
const renderItem = useCallback(
({ item }) => <Row item={item} onPress={onOpen} />,
[onOpen],
);
const keyExtractor = useCallback(item => item.id, []);
return (
<FlatList
data={products}
renderItem={renderItem}
keyExtractor={keyExtractor}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
removeClippedSubviews
/>
);
}
Props that actually matter
| Prop | What it does | Guidance |
|---|---|---|
keyExtractor |
Stable identity for recycling | Use a real id, never index alone for mutable lists |
getItemLayout |
Skip measuring if rows are fixed height | Big win when every row is the same height |
initialNumToRender |
First paint batch | Keep close to what's on screen (~8-12) |
maxToRenderPerBatch |
How many items per JS batch while scrolling | Lower = less JS spike, higher = fewer blanks |
windowSize |
Viewport multiples kept mounted | Default 21 is heavy; try 5-10 |
updateCellsBatchingPeriod |
Delay between batches (ms) | Raise slightly if scroll feels choppy from JS |
removeClippedSubviews |
Detach offscreen native views | Often helps Android; verify no missing views |
ListEmptyComponent |
Empty state | Avoid conditional whole-list unmount flicker |
Fixed-height turbo: getItemLayout
If every row is 72px tall:
const ITEM_HEIGHT = 72;
const getItemLayout = useCallback(
(_data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
}),
[],
);
<FlatList getItemLayout={getItemLayout} /* ... */ />
You skip async layout measurement. Jump-to-index and scroll restoration get cheaper and more accurate.
Variable-height rows? Skip this, or maintain your own offset cache carefully.
The extraData trap
FlatList does a shallow compare on data. Selection state living outside data won't re-render rows unless you tell it:
<FlatList
data={items}
extraData={selectedId} // force rows to update when selection changes
renderItem={({ item }) => (
<Row item={item} selected={item.id === selectedId} />
)}
/>
Inline functions and anonymous rows
These recreate every parent render and defeat memo:
// ❌ new function + new component type every render
<FlatList
renderItem={({ item }) => <Row item={item} onPress={() => go(item.id)} />}
/>
Prefer stable renderItem, memoized row, and stable handlers (pass id, handle in row, or use a context/ref for callbacks).
Images in FlatList rows
- Size images explicitly (
width/height) - Use a caching library (
react-native-fast-imageor Expo Image) - Don't decode huge assets into tiny thumbnails without resizing
- Use recycling-aware placeholders so recycled cells don't flash the wrong image (cancel / version tokens)
3. SectionList - grouped data without fighting FlatList
SectionList is FlatList's cousin for sectioned data:
[
{ title: 'A', data: [ /* contacts */ ] },
{ title: 'B', data: [ /* ... */ ] },
]
Solid pattern
import { SectionList, Text, View } from 'react-native';
import { memo, useCallback } from 'react';
const ContactRow = memo(function ContactRow({ item }) {
return (
<View style={styles.row}>
<Text>{item.name}</Text>
</View>
);
});
function ContactsScreen({ sections }) {
const renderItem = useCallback(
({ item }) => <ContactRow item={item} />,
[],
);
const renderSectionHeader = useCallback(
({ section: { title } }) => (
<View style={styles.header}>
<Text style={styles.headerText}>{title}</Text>
</View>
),
[],
);
return (
<SectionList
sections={sections}
keyExtractor={item => item.id}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
stickySectionHeadersEnabled
initialNumToRender={12}
windowSize={7}
removeClippedSubviews
/>
);
}
SectionList gotchas
- Same optimization rules as
FlatList- memo rows, stable keys, tune window props. - Sticky headers have platform quirks - test Android carefully.
- Don't rebuild
sectionsevery render with a fresh array/object graph if data didn't change - that invalidates list reconciliation. - Mixed item types (header-like rows inside
data) make recycling harder - keep headers as real section headers. - For very large sectioned datasets (e.g. 10k contacts), consider
FlashList+ manual sticky headers or a specialized contacts UI.
When SectionList is the wrong tool
- Fake sections by stuffing headers into a flat array and branching in
renderItem- works, but you lose sticky header semantics and complicategetItemLayout - Nested
SectionLists - Tiny static grouped content - a
ScrollViewwith plainViews is simpler
4. FlashList - when FlatList's recycling isn't enough
@shopify/flash-list is built for blank-free, high-FPS lists. It recycles views more aggressively and asks you to help with item type and size estimates.
Install
yarn add @shopify/flash-list
# iOS
cd ios && pod install
Baseline migration from FlatList
import { FlashList } from '@shopify/flash-list';
import { memo, useCallback } from 'react';
import { Text, View } from 'react-native';
const Row = memo(function Row({ item }) {
return (
<View style={styles.row}>
<Text>{item.title}</Text>
</View>
);
});
function Feed({ items }) {
const renderItem = useCallback(({ item }) => <Row item={item} />, []);
return (
<FlashList
data={items}
renderItem={renderItem}
keyExtractor={item => item.id}
estimatedItemSize={72} // critical in v1; confirm for your version
/>
);
}
💡 Version note: FlashList API evolved (v1 → v2). Always confirm current required props (estimatedItemSize vs newer sizing APIs) for the version you install. The ideas stay the same: estimate sizes, separate item types, keep rows pure.
Why FlashList feels faster
| FlatList | FlashList |
|---|---|
| Conservative recycling | Cell recycling tuned like native RecyclerView / UICollectionView |
| More blank cells under fast fling | Fewer blanks when configured well |
| Measurement churn on variable heights | Size estimates + recycling reduce thrash |
| Easy defaults, easier to misuse | Slightly more setup, better ceiling |
Item types - don't skip this
If row layouts differ (text-only vs image vs ad), tell FlashList so it recycles like-with-like:
<FlashList
data={items}
renderItem={renderItem}
estimatedItemSize={80}
getItemType={item => item.kind} // 'text' | 'media' | 'ad'
/>
Without this, a tall media cell may be reused for a short text row → layout jumps / wasted work.
Headers, footers, empty states
<FlashList
data={items}
ListHeaderComponent={Header}
ListFooterComponent={isLoading ? FooterSpinner : null}
ListEmptyComponent={Empty}
renderItem={renderItem}
estimatedItemSize={72}
/>
Grid with FlashList
<FlashList
data={products}
numColumns={2}
renderItem={renderItem}
estimatedItemSize={220}
/>
Keep column cell heights predictable. Wildly different cell heights in a grid are where you earn your performance scars.
FlashList as a SectionList alternative
FlashList doesn't replace SectionList 1:1. Common approach:
- Flatten sections into one array with
{ type: 'header' | 'row', ... } getItemTypereturns'header'vs'row'- Implement sticky headers via
FlashListsticky props / a custom overlay if you need them
More work than SectionList, but worth it at large scale.
Optimization checklist (all virtualized lists)
Copy/paste PR checklist.
Data & identity
- [ ] Stable
keyExtractor/ keys from server ids - [ ] Avoid using array index as key when items reorder/insert
- [ ] Don't clone data / sections every render
- [ ] Paginate on the server (
limit/cursor) - UI virtualization ≠ fetching the world
Row components
- [ ]
memorow components - [ ] Cheap rows: no heavy hooks work per row on every parent render
- [ ] Avoid anonymous
renderItemthat closes over changing values unnecessarily - [ ] Move press handlers carefully (stable callback or row-local)
- [ ] Don't put the entire screen state into every row via props
Layout
- [ ] Prefer fixed heights +
getItemLayout(FlatList / SectionList) - [ ] Provide accurate size estimates (FlashList)
- [ ] Separate heterogeneous layouts with item types (FlashList)
- [ ] No nested
VirtualizedLists in the same orientation - [ ] Use
ListHeaderComponentinstead ofScrollViewwrapping a list
JS thread hygiene
- [ ] Keep
renderItempure and fast - [ ] Debounce filtering/sorting; do heavy work off the critical path
- [ ] Prefer
InteractionManager.runAfterInteractionsfor non-urgent work after push - [ ] Profile with React DevTools + RN Perf monitor before "optimizing" randomly
Images & media
- [ ] Explicit dimensions
- [ ] Cache and prioritize visible URLs
- [ ] Blurhash / LQIP placeholders beat layout jumps
- [ ] Recycle-safe binding (don't show previous item's avatar)
Tuning knobs (FlatList / SectionList)
Start conservative, measure, then adjust:
initialNumToRender = {8}
maxToRenderPerBatch = {8}
windowSize = {5}
updateCellsBatchingPeriod = {50}
removeClippedSubviews
See blank areas while flinging → increase windowSize / batch size. See JS thread spikes → decrease them and lighten rows.
Choosing the right tool - decision flow
Is the content short and fixed?
YES →ScrollView(or plainView)
NO ↓Do you need sticky section headers with minimal setup?
YES →SectionList(orFlashList+ sticky strategy if huge)
NO ↓Is the list long / fling-heavy / heterogeneous / business-critical FPS?
YES →FlashList
NO →FlatListis fine
Practical defaults for common screens
| Screen | Recommendation |
|---|---|
| Settings | ScrollView |
| Inbox / notifications | FlashList (or tuned FlatList) |
| Chat messages | FlashList inverted / maintain visible content |
| Product catalog grid | FlashList + numColumns |
| Contacts A-Z | SectionList until scale hurts, then flatten + FlashList |
| Horizontal carousels | FlatList/FlashList horizontal; don't nest badly with parent vertical list |
| Search results | Virtualized list + pagination + cancel in-flight requests |
Nested lists without shooting yourself
Horizontal list inside vertical list is common (Netflix-style rows).
function ShelfRow({ shelf }) {
return (
<View>
<Text>{shelf.title}</Text>
<FlatList
horizontal
data={shelf.items}
keyExtractor={item => item.id}
renderItem={renderShelfItem}
showsHorizontalScrollIndicator={false}
/>
</View>
);
}
Comments
No comments yet. Start the discussion.