skip to content

A React Native FlatList of 10,000 stock-ticker rows shows blank rows on fast flings; what causes the blanks, and which props would you tune, at what cost?

level: seniorimportance: must knowfreq 55%

answer

  1. scroll speed versus render speed
  2. spacers stand in for unrendered rows
  3. batch size during a fling
  4. memory against responsiveness

basics

~20 s

The fling outruns the fill rate: rows beyond the render window are empty spacers until JavaScript renders the next batch. Lighter rows, getItemLayout, larger batches and a wider window help, costing JavaScript-thread time and memory.

solid answer

~40 s

`FlatList` mounts only a window of rows and fills the rest with spacer views, while the native scroll view moves at full speed. On a fast fling the viewport reaches spacers before JavaScript has rendered the next batch, so the user sees blank rows; ticker updates re-rendering rows make it worse by keeping the JavaScript thread busy. I would first test a release build with the feed paused, then make rows cheap and fixed-height with `getItemLayout`. Next, raise `maxToRenderPerBatch` so each pass covers more distance, lower `updateCellsBatchingPeriod` so the runway fills sooner, and widen `windowSize` only if memory allows. Each change trades blank areas for longer JavaScript work or more mounted rows, so every step is measured, and some blanking remains possible on slow devices.

code

tsx · 32 lines
tsx
import { memo, useCallback } from 'react';
import { FlatList, Text, View } from 'react-native';

type Tick = { symbol: string; priceText: string };
const ROW_HEIGHT = 56;

const TickerRow = memo(function TickerRow({ symbol, priceText }: Tick) {
  return (
    <View style={{ height: ROW_HEIGHT, flexDirection: 'row' }}>
      <Text>{symbol}</Text>
      <Text>{priceText}</Text>
    </View>
  );
});

export function TickerList({ ticks }: { ticks: Tick[] }) {
  const renderItem = useCallback(
    ({ item }: { item: Tick }) => <TickerRow symbol={item.symbol} priceText={item.priceText} />,
    [],
  );
  return (
    <FlatList
      data={ticks}
      keyExtractor={(t) => t.symbol}
      renderItem={renderItem}
      getItemLayout={(_, index) => ({ length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index })}
      initialNumToRender={16}
      maxToRenderPerBatch={20}
      updateCellsBatchingPeriod={30}
    />
  );
}

go deeper

for a junior

Recall that FlatList renders rows near the screen in batches, so scrolling faster than those batches arrive shows empty space.

for a middle

Explain the fill-rate mechanism: spacers for unrendered rows, batches limited by maxToRenderPerBatch, and why getItemLayout and cheap rows help.

for a senior

Show the diagnosis order on a release build, separate feed pressure from list tuning, and state what each prop costs in memory or JavaScript-thread time.

for a principal

Weigh tuning the existing list against redesigning rows, throttling the data feed, or adopting a different list implementation, and set a device-based acceptance test for the screen.

## Where the blank rows come from A React Native `FlatList` is built on `VirtualizedList`, which keeps only a **render window** of rows mounted and fills the rest of the scroll content with **spacer `View`s**. A spacer has the right size but no content. When the user flings a 10,000-row stock-ticker list, the native scroll view moves the content on the UI thread at full speed, while new rows can only appear after **JavaScript** renders them and the result is committed to the native side. If the scroll position runs past the last rendered row before the next batch lands, the user sees a spacer: a **blank area**. The blank is temporary: once the batch is committed, real rows replace the spacer at the same position. What users report as a bug is the window catching up with the scroll. So blank rows are a **fill-rate** problem: the list is rendering rows more slowly than the user is scrolling past them. The React Native docs call this out directly: content is rendered asynchronously off screen, so it is possible to scroll faster than the fill rate. A ticker makes it worse in two ways: - Price ticks keep the JavaScript thread busy re-rendering mounted rows, so batches of new rows start late. - Rows with sparklines, images or nested layout take longer per row, so each batch covers less distance. ## Diagnose before touching props 1. Reproduce on a **release build** on a low-end Android device; a development build runs extra checks and exaggerates the problem. 2. Pause the price feed and fling again. If the blanks mostly disappear, the fix is to throttle or batch ticks, not to tune the list. 3. Check what a row costs to render: layers of `View`s, images, formatting work inside `renderItem`. 4. Turn on the list's `debug` prop briefly for its extra logging and visual overlays; remove it afterwards, because it has a significant performance cost. ## The knobs and what each one costs | Prop | Change | How it reduces blanks | What it costs | |---|---|---|---| | `maxToRenderPerBatch` | raise from `10` | More rows per pass, so each pass covers more distance | Longer JavaScript blocks; presses can respond late | | `updateCellsBatchingPeriod` | lower from `50` ms | The runway ahead of the viewport fills sooner | More frequent JavaScript work | | `windowSize` | raise from `21` | More pre-rendered runway before a fling hits the edge | More mounted rows and memory; every data change touches more rows | | `initialNumToRender` | size to one screen | No blank first frame | Too high delays time to content | | `getItemLayout` | add for fixed-height rows | Sizes and offsets come from arithmetic, not measurement | Must be exactly right, including separators | One subtlety matters on a fling. When the viewport has already reached the edge of the rendered rows, `VirtualizedList` renders **immediately** instead of waiting for `updateCellsBatchingPeriod`, but each pass still adds at most `maxToRenderPerBatch` new rows. So during a fast fling, **batch size and per-row cost** decide how quickly the blank closes; the period mostly affects how full the runway is before the fling starts. ## A tuned starting point for the ticker Fixed-height rows allow `getItemLayout`, a cheap memoized row, and a larger batch. The numbers are starting points to measure against, not recommendations: each change should be judged by fling tests on the release build and by memory use. ## When tuning is not enough - **Lighter rows** beat bigger numbers: pre-format prices before they reach the row, drop per-row images, flatten nested `View`s. - **Throttle the feed**: coalescing ticks to a few updates per second frees the JavaScript thread for rendering new rows. - **Keep the window honest**: raising `windowSize` far above the default trades blanks for memory, and on a 10,000-row list memory pressure shows up on low-end devices first. - A list implementation that **recycles** cells instead of mounting new ones is a separate option with its own tradeoffs; it is a different component, not a `FlatList` prop. Blank areas can be reduced but not ruled out: a fast enough fling on a slow enough device will always outrun rendering, which is the tradeoff windowing makes to keep memory bounded.

  • Why not set disableVirtualization to remove the blanks?
    It mounts every row, so a 10,000-row list pays the full render time and memory for rows nobody sees, and every data change touches all of them. The prop is marked deprecated in the source and documented as a debugging aid only, not a production fix.
  • During a fast fling, does lowering updateCellsBatchingPeriod close the blank quickly?
    Only a little. Once the viewport reaches the edge of the rendered rows, `VirtualizedList` renders immediately without the timer, but each pass still adds at most `maxToRenderPerBatch` rows. Batch size and per-row cost dominate during the fling; the period mostly decides how much runway was ready before it.
  • How do you confirm a tuning change actually helped?
    Repeat the same fling on a release build on a low-end device before and after, watch for blank spacers, and compare memory use, since a wider window can hide blanks while pushing memory up. The list's `debug` prop adds logging and visual overlays during diagnosis, but it slows the list and must be removed.

saying these in an interview costs you the question

  • Blank rows mean the next page has not arrived from the server
  • A very large windowSize removes blanks with no downside
  • disableVirtualization is the right production fix for blank rows
  • Blank areas are a native drawing bug that JavaScript cannot influence
  • Tuning values measured in a development build carry over to release