Skip to content
·6 min read

Your React Native FlatList Is Janky (And Has Been Since Day One)

FlatList jank is the #1 complaint from RN teams. Most fix it wrong — wrapping rows in memo, bumping initialNumToRender. Here's what actually causes scroll jank.

React NativePerformanceMobileFlatList

FlatList jank is the number one complaint I hear from React Native teams. The scroll stutters. Rows pop in. The first few seconds feel broken.

Most teams fix it wrong. They wrap every row in React.memo, crank initialNumToRender to 20, and ship a profile that's 5% better. Then they live with the jank.

The real cause is almost always one of three things: missing getItemLayout, unstable props in renderItem, or unoptimized images. Here's how I actually fix it.

The actual cause of scroll jank

FlatList measures every row by default. If a row is off-screen and FlatList doesn't know its height, it renders it invisibly to measure, then positions it. For a list of 500 items, that's 500 invisible renders.

This is the silent killer. The fix is getItemLayout — a function that tells FlatList the exact position and length of every row, no measurement needed.

<FlatList
  data={items}
  renderItem={renderItem}
  getItemLayout={(data, index) => ({
    length: ROW_HEIGHT,
    offset: ROW_HEIGHT * index,
    index,
  })}
/>

For fixed-height rows this is trivial and gives you instant scroll-to-index for free. For variable-height rows, you have to compute the cumulative offset yourself — which is annoying but worth it past 100 rows.

Fix #2: stop defeating React.memo

I see this in every codebase:

// Every render creates a new function reference
const renderItem = ({ item }) => (
  <ProductRow
    item={item}
    onPress={(id) => navigateToProduct(id)}
    style={{ backgroundColor: 'white' }}
  />
)

Three problems:

  1. onPress is a new function every render
  2. style is a new object every render
  3. Even if ProductRow is wrapped in React.memo, it re-renders every time

The fix:

// Hoist stable references
const ROW_STYLE = { backgroundColor: 'white' }
 
const handlePress = useCallback(
  (id) => navigateToProduct(id),
  [navigateToProduct]
)
 
const renderItem = useCallback(
  ({ item }) => (
    <ProductRow item={item} onPress={handlePress} style={ROW_STYLE} />
  ),
  [handlePress]
)
 
const ProductRow = memo(function ProductRow({ item, onPress, style }) {
  // ...
})

Now ProductRow only re-renders when its item actually changes. This is the single biggest FlatList win I've shipped — went from 30fps to 60fps on a client's product catalog.

Fix #3: images are probably your real bottleneck

Nine times out of ten when a team says "FlatList is slow," the actual bottleneck is image decoding on the JS thread.

The rules:

  • Use a proper image library (expo-image, react-native-fast-image) — not the built-in Image
  • Set resizeMode explicitly (default is cover which can be expensive)
  • Pass explicit source dimensions where possible
  • Cache aggressively — same URL should never decode twice
import { Image } from 'expo-image'
 
<Image
  source={item.imageUrl}
  style={styles.image}
  contentFit="cover"
  transition={150}
  cachePolicy="memory-disk"
/>

expo-image caches at the native level and decodes off the JS thread. The difference on a long list is night and day.

Fix #4: dial in the rendering budget

FlatList ships with conservative defaults that work for short lists and crush long ones.

<FlatList
  data={items}
  renderItem={renderItem}
  maxToRenderPerBatch={4}   // default 10 — too many for low-end devices
  windowSize={8}            // default 21 — keeps too many rows mounted
  initialNumToRender={6}    // what shows above the fold
  removeClippedSubviews={Platform.OS === 'android'}
/>

Lower maxToRenderPerBatch spreads rendering across more frames (smoother scroll, slightly slower initial load). Lower windowSize reduces memory.

Test on a real Android device, not the simulator. The simulator hides jank that real users feel.

Fix #5: when FlatList isn't enough

If you've done all of the above and the list is still janky on low-end devices, switch to FlashList from Shopify. It recycles cells at the native level — like UITableView on iOS — instead of mounting and unmounting React components.

import { FlashList } from '@shopify/flash-list'
 
<FlashList
  data={items}
  renderItem={renderItem}
  estimatedItemSize={80}
/>

estimatedItemSize is required and replaces getItemLayout. The API is close enough that most FlatList code ports in 10 minutes.

I've measured FlashList at 2-3x the FPS of a well-optimized FlatList on lists over 1,000 items. For short lists (under 50 items), the difference is negligible — don't add a dependency you don't need.

The workflow I use

  1. Profile on a real device with React DevTools Profiler
  2. Find the row component that's re-rendering most
  3. Stabilize its props (hoist styles, useCallback, useMemo)
  4. Add getItemLayout if rows are fixed-height
  5. Switch to expo-image if the row renders images
  6. Dial down maxToRenderPerBatch and windowSize
  7. If still janky past 1,000 rows, migrate to FlashList

Steps 2 and 3 fix 70% of cases. Step 4 is the secret weapon most teams skip.

FlatList is not slow. Your renderItem is doing too much. Stabilize the props, add getItemLayout, and watch the jank disappear.

Want a React Native app that doesn't jank?

I build React Native apps that hit 60fps on real devices — not just on the simulator. Let's talk.