LEARN · DEBUGGING GUIDE

React Native FlatList Performance: Why Your List Jitters at 200+ Items

FlatList isn't magically fast out of the box. If your list stutters above 200 items, you're paying for missing keys, anonymous functions, or wrong windowSize. Here's exactly how to find and fix each cause.

AdvancedMobile8 min read

What this usually means

FlatList performance problems almost always come down to one of three things: (1) the list doesn't know the item sizes, so it can't calculate the visible window efficiently; (2) every render creates new objects or functions, breaking PureComponent and React.memo optimization; or (3) the data structure itself causes unnecessary re-renders (e.g., mutating arrays instead of returning new references). The default windowSize (21) is often too large for complex items, causing too many simultaneous renders. I've seen teams spend days on 'optimizing' renderItem when the real fix was adding getItemLayout or removing an inline arrow function.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Enable the built-in FPS monitor: run `adb shell setprop debug.hwui.profile visual_bars` on Android or use Xcode's Core Animation profiler on iOS
  • 2Check the VirtualizedList warning in Metro: if you see 'You have a large list that is slow to update', you're missing key or getItemLayout
  • 3Profile with React DevTools: open Profiler tab, record a scroll session, look for commits that take >16ms
  • 4Run `console.log(item)` inside renderItem to see how many times items re-render per scroll — if >2x visible items, you have prop instability
  • 5Wrap renderItem in React.memo and add a second argument that compares props deeply — if still re-rendering, check inline functions
  • 6Check `keyExtractor` — if you use index, every reorder or insert will cause full re-render
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchrenderItem function — look for arrow functions or `.bind(this)` inside the JSX
  • searchgetItemLayout — if missing or returning wrong sizes, FlatList recalculates on every scroll
  • searchkeyExtractor — using index is a red flag; use a unique id from your data
  • searchItemSeparatorComponent / ListFooterComponent — any inline component creates new refs each render
  • searchReact DevTools Profiler flamegraph — check for 'rendered by <Unknown>' nodes
  • searchSystrace (Android) or Instruments (iOS) — look for long 'UI Thread' or 'JavaScript' spans
  • searchPackage.json — check if you're using an outdated version of react-native (pre-0.63 had known FlatList perf issues)
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningMissing getItemLayout — FlatList falls back to measuring every cell, causing layout thrash
  • warningAnonymous arrow functions in renderItem — each scroll creates new function instances, breaking memoization
  • warningUsing index as key — any data mutation shifts keys, causing full re-render of all items
  • warningLarge state objects in the item component — passing full objects instead of primitives forces deep diffs
  • warningWindowSize too large — default 21 means 21 screen heights of content rendered simultaneously
  • warningRe-rendering the entire list on every state update — not using useMemo or useCallback for stable references
  • warningThird-party libraries that wrap FlatList with additional state (e.g., animated scroll handlers)
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildAdd getItemLayout with fixed item height: `getItemLayout={(data, index) => ({ length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index })}`
  • buildMemoize renderItem: `const renderItem = useCallback(({item}) => <ItemComponent item={item} />, [])` and wrap ItemComponent in React.memo
  • buildNever use index as key — always use a stable unique id from your data, even if you have to add one
  • buildReduce windowSize to 7-11 for simple items, or 3-5 for complex items with images
  • buildUse `removeClippedSubviews={true}` on Android (but test for scroll position bugs)
  • buildFor dynamic heights, measure once with onLayout and cache the result in a map
  • buildUse `React.PureComponent` or `React.memo` with a custom comparator (e.g., shallowEqual from react-redux)
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedRun the FPS monitor again — sustained 60 FPS during rapid scroll (30 FPS on low-end devices)
  • verifiedCheck VirtualizedList warning is gone from Metro console
  • verifiedUse React DevTools Profiler to confirm <16ms commits per frame
  • verifiedScroll to bottom and back to top — no blank cells should appear
  • verifiedMemory profile: heap should stay stable after scrolling through 1000 items and back
  • verifiedOn Android, run `adb shell dumpsys gfxinfo <package> framestats` — look for '90th percentile' <16ms
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningAdding getItemLayout with wrong height — causes misalignment and blank cells
  • warningUsing `React.memo` with inline arrow function as second argument (creates new function every render, breaking memo)
  • warningOver-aggressive `initialNumToRender` (default 10 is fine; lowering causes more blank cells)
  • warningMutating data array (e.g., `push` instead of `concat`) — FlatList won't detect changes
  • warningPutting heavy computations inside renderItem (like image processing or deep object cloning)
  • warningSetting `maxToRenderPerBatch` too high — can cause frame drops on initial render
( 07 )War story

The 10-Second Scroll: How a Missing getItemLayout Crashed a Booking App

Senior React Native EngineerReact Native 0.66, Android 11, Redux Toolkit, react-navigation 6

Timeline

  1. 09:15User reports that scrolling through the booking list (500+ hotels) is 'impossible' — app freezes for 2-3 seconds on each swipe
  2. 09:20I reproduce on a Pixel 3a: FPS drops to 12-15 during scroll, Metro warning 'VirtualizedList: large list slow'
  3. 09:30Check renderItem — uses inline arrow function: `renderItem={({item}) => <HotelCard hotel={item} />}`
  4. 09:35Check keyExtractor — uses `item.id` (good), but no getItemLayout
  5. 09:45Add React.memo to HotelCard and useCallback for renderItem — still 20 FPS
  6. 10:00Profile with React DevTools: every scroll triggers re-render of all 10 visible items (should be 0)
  7. 10:10Check HotelCard props: `hotel` object changes reference every render because Redux selector returns new object
  8. 10:20Fix selector with shallowEqual (reselect) — still 30 FPS
  9. 10:30Add getItemLayout with fixed height (120px) — FPS jumps to 55-60, no warnings
  10. 10:45Further reduce windowSize from 21 to 9 — stable 60 FPS
  11. 11:00Deploy fix: getItemLayout + memo + stable selectors + windowSize=9

The bug report came in as a P0: 'Booking list unusable on Android.' I pulled the logs — no crash, but the UI thread was blocking for 300-500ms per scroll event. The list had 500+ hotel cards with images, ratings, and prices. I immediately opened the FPS monitor and saw 12 FPS. The Metro warning confirmed it: missing getItemLayout.

I started with the obvious: wrapped renderItem in useCallback and added React.memo to HotelCard. FPS improved to 20 — better but still terrible. Then I used React DevTools Profiler. Every scroll was re-rendering all visible items. I checked the props: the `hotel` object was a new reference every time because the Redux selector was returning a new object. Fixed with `reselect` and shallowEqual — now 30 FPS.

Still not 60. I added getItemLayout with a fixed height (all cards were exactly 120dp). Boom — 55-60 FPS immediately. Then I reduced windowSize from 21 to 9 to cut offscreen renders. Final FPS: 60 stable. The root cause was a chain: no layout hint forced measurements, plus unstable props caused re-renders, plus too large window. Combined, they crushed performance.

Root cause

Missing getItemLayout forced FlatList to measure every cell dynamically, causing layout thrash on every scroll, amplified by unstable Redux selector references and default windowSize=21.

The fix

Added getItemLayout with fixed 120dp height, memoized renderItem with useCallback, wrapped HotelCard in React.memo, fixed Redux selector with shallowEqual, reduced windowSize to 9.

The lesson

Always add getItemLayout if you can. Profile before optimizing — the biggest gains often come from the simplest change (layout hint). Don't trust default values.

( 08 )How getItemLayout Eliminates Measurement Jank

FlatList relies on VirtualizedList, which needs to know the offset of each item to render only the visible ones. Without getItemLayout, it measures every cell on mount by rendering it offscreen and reading its height. This causes a cascade of layout passes — each new cell forces the previous ones to re-layout. With 500 items, that's 500 synchronous measurements on initial render and 500 more on every scroll if items are recycled.

When you provide getItemLayout with a fixed length, VirtualizedList can compute offsets in O(1) — no measurements needed. The formula is simple: `offset = length * index`. This works even if some items are shorter (you'll see blank space, but no jank). For variable heights, you can implement a binary search over a cached heights array, but fixed height is always faster. I've seen a 4x FPS improvement just from this one change.

( 09 )The Perils of Anonymous Functions and Inline Objects

Every time renderItem is called, if you pass an inline arrow function (e.g., `renderItem={({item}) => ...}`), a new function object is created. React.memo or PureComponent compares by reference, so it sees 'new props' every render and re-renders. The fix is to define renderItem outside the render method with useCallback, and pass only stable references.

Same goes for style objects: `style={{ margin: 10 }}` creates a new object every render. Move styles out of render with StyleSheet.create. Also watch for spreading props: `<ItemComponent {...item} />` creates a new object each time. Destructure only the fields you need: `<ItemComponent name={item.name} price={item.price} />`. This gives React.memo a chance to compare by value.

( 10 )Tuning windowSize, maxToRenderPerBatch, and initialNumToRender

windowSize controls how many screen heights of content are rendered. Default 21 means 10.5 screens above and 10.5 below the visible area. For complex items (images, animations), this is overkill. Reduce to 5-7 for smooth scrolling with 60 FPS. But too low (e.g., 3) causes blank cells on fast scroll. Tune empirically: start at 7, reduce until you see blanks, then add 2.

maxToRenderPerBatch (default 10) controls how many items are rendered per batch after the initial set. Increasing it can cause frame drops on initial load; decreasing it makes the list appear slower to fill. I keep it at 10. initialNumToRender (default 10) controls the first visible set. For lists that start at the top, 10 is fine. For lists that restore scroll position, you might need more to avoid blanks.

( 11 )Using Systrace and React DevTools Profiler for FlatList Performance

Android Systrace gives you a timeline of UI thread, RenderThread, and JavaScript thread. With FlatList jank, look for long 'Choreographer' or 'UI Thread' spans (>16ms). If the JavaScript thread is maxed out, the problem is rendering logic (renderItem). If UI thread is blocked, it's layout measurement (missing getItemLayout) or native view recycling.

React DevTools Profiler (available with React Native 0.62+) shows component renders per commit. Filter by 'rendered by' to find components that re-render unexpectedly. A healthy FlatList should show 0 commits during idle scroll (no state changes). I look for commits that take >10ms — those are your optimization targets. Combine with `console.log` inside renderItem to catch render counts.

( 12 )Handling Dynamic Heights Without Losing Performance

If you can't use fixed heights, the next best thing is to measure each item once with onLayout and cache the height in a map. Then implement getItemLayout that reads from the cache (defaulting to an estimated height). This avoids the initial measurement cascade but still requires one measurement per unique item. For lists with thousands of items, this is acceptable.

Alternative: use a library like @shopify/flash-list, which uses a different virtualization algorithm (cell recycling with fixed window). It's 2-5x faster than FlatList for dynamic heights. But if you're stuck with FlatList, the cache approach works. I've also seen teams use `CellRendererComponent` with a custom wrapper that measures children — but that's complex and often slower than FlashList.

Frequently asked questions

Does FlatList automatically recycle views like RecyclerView on Android?

Yes, FlatList uses VirtualizedList which recycles native views (like RecyclerView). But it still renders React components for each visible item. If those components are heavy or re-render on every frame, you'll see jank. The recycling only avoids creating new native views; it doesn't avoid JavaScript re-renders. That's why memoization and stable props matter.

Should I use FlatList or SectionList for large lists with sections?

SectionList is built on top of VirtualizedList and has the same performance characteristics — it actually renders more components because of section headers. For performance, use FlashList if possible. If you must use SectionList, apply the same optimizations: getItemLayout (with section offsets), memoized renderSectionHeader, and stable keys.

Why does my FlatList work fine on iOS but lag on Android?

Android's UI thread is more sensitive to layout jank because of the Choreographer's 16ms deadline. iOS has a more forgiving rendering pipeline. Also, Android's RecyclerView (which FlatList uses) has different recycling behavior. Common culprits: missing getItemLayout (Android measures more aggressively), absence of `removeClippedSubviews`, and different image rendering pipelines.

Is it safe to use index as key if the list never changes?

Even if the list doesn't change, using index as key prevents FlatList from properly recycling views. When you scroll, items are recycled, but the key (index) stays the same for each slot, causing state leaks (e.g., input fields retain values from previous items). Always use a unique id — even if you have to generate one with a UUID library. It's a best practice that avoids subtle bugs.

How do I handle extremely large lists (10,000+ items) without crashing?

FlatList can handle 10,000 items if you have getItemLayout and proper memoization, but memory usage will be high because the data array is in memory. For truly large lists, consider virtualized solutions like FlashList (which uses a pool of reusable components) or windowed rendering with react-window (for web). Also, paginate your data — load 1000 items at a time and use onEndReached to fetch more.