Skip to content

Long lists scroll badly on mobile

The situation

The feed works, but on a real device fast scrolling shows blank patches where rows haven’t rendered yet; the scroll hitches on a two-year-old Android; and opening the screen takes a beat before anything appears. You’re already using FlatList — isn’t that supposed to be the optimized one?

Diagnosis: why it happens

FlatList is virtualized — it renders a window of items, not all of them (same principle as on the web). But virtualization has a race built in: as you scroll, items entering the viewport must be rendered by the JS thread before they can be drawn. Native scrolling runs on the UI thread and doesn’t wait (see Mobile fundamentals). If rendering an item costs 50ms and a fast flick brings in 10 items per second, JS can’t keep up — the scroll shows the fallback: blank cells.

So blank cells and scroll jank are usually item render cost, multiplied by mobile CPUs. The typical heavy item: deep view trees (nested cards), non-memoized components re-rendering on any parent update, inline closures/objects re-created per render defeating memoization, full-resolution images decoded for 80px thumbnails, and per-item onLayout/measurement work.

Recommendation: make items cheap, then let FlashList recycle them

Step 1 — cheap, memoized items. This is where most of the win lives, whatever list component you use:

Before — every parent render re-renders every visible row; images oversized:

<FlatList
data={posts}
renderItem={({ item }) => (
<Post post={item} onPress={() => open(item.id)} /> // new closure every render
)}
/>

After — memoized rows, stable callbacks, sized images:

const PostRow = memo(function PostRow({ post, onOpen }: Props) {
return (
<Pressable onPress={() => onOpen(post.id)}>
<Image source={{ uri: post.thumb }} style={{ width: 80, height: 80 }} />
{/* thumb = server-resized 160px variant, not the 4MB original */}
<Text numberOfLines={2}>{post.title}</Text>
</Pressable>
)
})
const onOpen = useCallback((id: string) => open(id), [])
<FlatList data={posts} renderItem={({ item }) => <PostRow post={item} onOpen={onOpen} />} />

Flatten the item’s view tree while you’re there — every nested View is a real native view with measure/draw cost.

Step 2 — recycle instead of re-mount: FlashList. Shopify’s drop-in replacement reuses off-screen cells (the pattern native UICollectionView/RecyclerView always used) instead of unmounting and re-creating them, and it wants an estimatedItemSize:

import { FlashList } from '@shopify/flash-list'
<FlashList data={posts} renderItem={renderPost} estimatedItemSize={96} />

The honest requirement: recycled components must render correctly when re-bound to different data — item-local useState that assumes “fresh mount per item” will leak state between rows. Cheap items + recycling routinely takes a janky feed to 60fps on mid-range hardware.

See the blank-cell race for yourself — flick-scroll hard:

When does it matter? A 30-row settings list needs none of this. It matters for feeds/catalogs of hundreds+ of items with images, on the devices your users actually own. Blank-cell reports from the field are the unambiguous trigger. And measure on a mid-range Android in release mode — dev mode’s overhead makes every list look guilty.

Discarded alternatives (and when they ARE the right call)

Tuning FlatList props (windowSize, maxToRenderPerBatch, removeClippedSubviews)

  • What it solves: trades memory vs blank-risk vs batch jank around the existing render cost — a bigger window renders further ahead, smaller batches interleave better.
  • Why it’s not the first option here: it repositions the problem instead of shrinking it: with a 50ms item, every knob setting is bad. Tune after items are cheap, not instead.
  • When it IS the right call: after steps 1–2, for the last mile — e.g. raising windowSize on image-heavy feeds to pre-render further ahead, at a memory cost.

Pagination / load-more instead of a long list

  • What it solves: initial render time and memory — fetch and hold 20 items, not 2,000.
  • Why it’s not the first option here: it bounds how much data is loaded, not how expensive each row is — scroll jank and blank cells survive pagination intact. You should fetch incrementally anyway (onEndReached); it’s just not the fix for this symptom.
  • When it IS the right call: always, as the data-fetching strategy, paired with keyset pagination on the backend (Your endpoint is slow).

getItemLayout (skip measurement for fixed-height rows)

  • What it solves: for fixed-size rows, FlatList skips async layout measurement — scroll-to-index works instantly, less layout work per batch.
  • Why it’s not the first option here: measurement is rarely the dominant cost — render is. It also silently breaks (misaligned scroll) the day rows stop being uniform.
  • When it IS the right call: genuinely uniform rows on plain FlatList — it’s a free, real win there (FlashList’s estimatedItemSize plays the same role more forgivingly).

Giving up on virtualization (ScrollView with all items)

  • What it solves: no blank cells ever — everything is always rendered; simplest mental model.
  • Why it’s not the first option here: mount cost and memory scale with the whole list; a 2,000-item ScrollView takes seconds to appear and can OOM-crash on Android. It trades a scroll artifact for a startup problem.
  • When it IS the right call: short, bounded lists (< ~50 light items) — settings screens, small pickers — where a list component’s machinery is pure overhead.

How to measure before and after

  • Perf monitor (dev menu): JS FPS while flick-scrolling. Dropping to single digits = JS-bound item rendering — this case. UI FPS dropping too = something rarer (deep native view trees, shadows/blur overdraw).
  • React DevTools Profiler: how many PostRow renders per scroll gesture and their duration. Post-memoization, rows should render only when entering the window, in single-digit ms.
  • The honest device test: release build, mid-range Android, flick hard. Blank cells before vs after is the user-visible metric everything else approximates.
  • FlashList ships built-in performance hooks (onBlankArea and friends) — the blank-cell rate as an actual number.

Signals you need something else