Lists & Performance

Lesson 1 of 5

FlatList and Virtualization

"The list is the origin of culture." — Umberto Eco, The Infinity of Lists (2009)

Almost every screen in almost every app is a list, and almost everything anybody knows about FlatList is a slogan: it only renders what is on screen.

It does not. I measured it. With the default configuration, a list showing ten rows has around two hundred and eleven of them rendered — a hundred above the viewport, ten in it, a hundred below. That is not a bug or a leak; it is precisely what the constants in the source ask for, and it is the difference between a list that scrolls smoothly and one that shows blank space every time you flick.

Virtualization is not about rendering less. It is about rendering a bounded amount regardless of how much data there is, and about choosing where that bound sits — which turns out to be a decision with four numbers in it, all of which have defaults, and none of which mean what their names suggest until you read the arithmetic.

The big question

A list of ten thousand rows cannot render ten thousand components. But rendering exactly the ten that are visible guarantees the user sees blank space the instant they scroll, because a new row cannot be mounted, measured and drawn within one frame. Every virtualized list therefore renders more than it shows, and the amount is a trade between memory and blankness. Where should that line sit — and who is better placed to decide it, the framework or you?

Virtualization is not "render what is visible"

Start with the constants, because they are stated plainly in one file and almost never quoted.

VirtualizedListProps.js defines a helper per prop, each of which is one line:

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Two of those units are the whole chapter. windowSize and the reached-thresholds are measured in viewports, not rows and not pixels, which is why tuning them by intuition goes wrong: windowSize: 2 sounds conservative and is in fact drastic, and onEndReachedThreshold: 0.5 is half a screen rather than half a list.

An analogy

A cinema projector with a film reel. What the audience sees is one frame, but the mechanism holds a loop of film slack on either side of the gate — several seconds' worth, above and below — because a projector that pulled each frame directly off the reel would jerk, tear the film, and show the audience the gap. The slack is not waste; it is what makes the motion continuous, and the size of the loop is a real engineering decision with a cost in mechanism and a benefit in smoothness. A virtualized list is exactly that. The viewport is the gate, windowSize is how much slack loop to keep on each side, and rendering only what is visible would be threading the film directly through the gate: minimal, correct in principle, and visibly terrible.

The window is twenty-one screens

The arithmetic is in computeWindowedRenderLimits, and the four lines that matter are these:

const visibleBegin = Math.max(0, offset);
const visibleEnd = visibleBegin + visibleLength;
const overscanLength = (windowSize - 1) * visibleLength;

const leadFactor = 0.5; // Math.max(0, Math.min(1, velocity / 25 + 0.5));

const overscanBegin = Math.max(0, visibleBegin - (1 - leadFactor) * overscanLength);
const overscanEnd = Math.max(0, visibleEnd + leadFactor * overscanLength);

overscanLength is twenty viewports. leadFactor splits it evenly, ten each way. And the commented-out expression beside leadFactor is an artefact worth noticing: the list used to bias the window in the direction of travel, based on velocity, and the comment two lines above says why it stopped — "Considering velocity seems to introduce more churn than it's worth."

Run it and the numbers stop being abstract:

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

That table is the argument against tuning windowSize downward as a reflex. Going from 21 to 5 cuts the mounted rows by three quarters, which is a real memory saving on a heavy row — and it also cuts the slack from ten screens of runway to two, which a fast flick will outrun.

A little history

The problem FlatList solves is older than React Native and has been solved differently by each platform. iOS shipped UITableView in 2008 with cell reuse at its centre: a fixed pool of cell objects is recycled as the user scrolls, and dequeueReusableCellWithIdentifier is the API every iOS developer learns early. Android arrived at the same idea in ListView and then rebuilt it properly as RecyclerView in 2014, whose name is the design. React Native could not copy either directly, because React's model is declarative — you do not hand the framework a pool of views to recycle, you describe what should exist and let the reconciler work out the rest. So VirtualizedList recycles nothing. It renders a windowed slice of the data and lets React unmount what falls outside, which is a genuinely different mechanism with a different cost profile: no pooling, no reuse identifiers, and no requirement that rows be interchangeable, in exchange for mounting and unmounting real components as the window moves. That is also why the ecosystem's fastest list libraries are the ones that reintroduce recycling underneath React rather than accepting the unmount.

Here is the same measurement in the playground, against the real implementation. React Native Web vendors React Native's own VirtualizedList — the defaults in its copy are the identical 10, 10 and 21 — so this is the real algorithm running in your browser, not an approximation of it.

React — Try it Yourself
Loading editor…
Preview

The two things to watch are that the count is much larger than the visible rows, and that it is bounded: scrolling to row 900 does not mount 900 components. Bounded, not minimal, is the property virtualization actually provides.

It gets there ten cells at a time

Rendering two hundred components in one frame would itself be a stall, so the list does not. The rest of computeWindowedRenderLimits starts from the visible range and expands outward one cell at a time, stopping when it has added maxToRenderPerBatch new cells, and picking up again after updateCellsBatchingPeriod milliseconds.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

The loop is stopped early in the real implementation when the list is scrolling, so the work happens in the gaps rather than competing with the scroll. The shape to hold is that initialNumToRender governs the first paint, maxToRenderPerBatch and updateCellsBatchingPeriod govern how fast the runway is built, and windowSize governs how long the runway is.

From my notebook

I have repeated the "only renders what is visible" line in code review, in interviews and probably in writing, and the real figure is two hundred and eleven rows for ten visible. What makes it worth recording is that both halves of my belief were wrong in opposite directions. I thought virtualization rendered fewer components than it does, and I also thought the saving was the point — whereas the actual design is that the number is bounded, and the bound is set deliberately high because blank space on a flick is a worse failure than holding two hundred lightweight views. The commented-out leadFactor expression is the detail that convinced me this was considered rather than arbitrary: someone implemented a velocity-biased window, measured it, found it caused more churn than it saved, and left the dead expression in place beside the constant that replaced it. That is a note from one engineer to the next saying "we tried this", and it is more informative than the documentation.

What the list has to measure, and what you can tell it

A windowed list needs to know where every row is, and by default it does not — it discovers each row's height from onLayout as that row mounts, and approximates the rest from what it has seen. That works, and it costs two things: the approximation is wrong until enough rows have been measured, so scrollToIndex on an unmeasured row is unreliable; and every mount does layout work that could have been arithmetic.

getItemLayout is how you skip all of it, and it is worth it whenever rows are a known height:

getItemLayout={(data, index) => ({ length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index })}

The other thing the list needs is identity, and the default is a trap worth knowing. FlatList's built-in key extractor is six lines:

export function keyExtractor(item: any, index: number): string {
  if (typeof item === 'object' && item?.key != null) return item.key;
  if (typeof item === 'object' && item?.id != null) return item.id;
  return String(index);
}

An object with a key or an id is handled. Everything else falls back to the array index, silently, which is correct for a list that never changes and wrong the moment one does.

React — Try it Yourself
Loading editor…
Preview

Tick the first row of each list and reverse. In the index-keyed list the tick stays in position one and is now attached to a different person; in the id-keyed list it follows the person. Neither list errors, and only one of them is right.

This matters more in a virtualized list than in a plain map, because rows mount and unmount constantly as the window moves — so a mismatched key does not merely misplace state on reorder, it can attach a row's state to whatever scrolls into its position.

Tuning, and when not to

The four numbers are adjustable and the honest advice is to leave them alone until you have measured something.

Raise initialNumToRender only if the first screen is visibly short — it delays first paint, which is the most expensive moment in the list's life. Lower windowSize when rows are genuinely heavy and memory is the constraint you measured, accepting more blank space on fast scrolls; raise it when users flick hard through light rows. Raise maxToRenderPerBatch to fill the runway faster at the cost of larger chunks of work per pass. And set removeClippedSubviews knowing it is a native-side optimisation with platform-specific quirks rather than a free win.

The two changes that are almost always worth making are not tuning at all: supply getItemLayout when rows are a fixed height, and supply a keyExtractor that returns a stable identity. Both remove work rather than rebalancing it.

In the real world

The bug that gets filed is "the list flickers white when I scroll fast", and the usual cause is a well-meaning windowSize reduction made during an earlier memory investigation, often by someone who has since left. The second and more common cause is an expensive renderItem: at the defaults the list is mounting around two hundred rows, so a row that does any real work — formatting a date, building a style object, rendering an unmemoised subtree — pays that cost two hundred times rather than ten, and the fix is in the row rather than in the list. The one that is hardest to diagnose is the index key, because it produces no error and no visual glitch until the data reorders, and lists that reorder tend to do so in production rather than in fixtures: a sort control, a pull-to-refresh that returns a new ordering, an item deleted from the middle. What the user reports is that they ticked one row and a different one became ticked, which sounds impossible and is a one-line fix.

Common Mistakes

  1. Believing the list renders only what is visible. At the defaults it renders about twenty-one viewports' worth.
  2. Reading windowSize as a row count. It is measured in viewports, so 21 is twenty-one screenfuls.
  3. Setting windowSize: 1 to "optimise". That renders only the visible rows and guarantees blank space on every flick.
  4. Reading onEndReachedThreshold as rows or a percentage. It is also in viewports; 2 means two screen-heights of runway.
  5. Doing real work in renderItem. Whatever it costs, it costs two hundred times, not ten.
  6. Relying on the default key extractor. Without a key or id on the item it falls back to the array index, silently.
  7. Keying by index in a list that reorders. State attaches to positions rather than to items, with no error.
  8. Omitting getItemLayout for fixed-height rows. The list then measures every row it mounts and guesses the rest.
  9. Expecting scrollToIndex to be exact on unmeasured rows. Without getItemLayout the offsets are approximations.
  10. Tuning the four numbers before measuring. They trade against each other, and the defaults are a considered position.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
What does FlatList do that a map inside a ScrollView does not?
Model answer — It renders a bounded slice of the data rather than all of it. A ScrollView with a mapped array mounts every row up front, so a thousand items means a thousand components before anything appears; a FlatList renders a window around the viewport and unmounts rows as they fall outside it, so the work is proportional to the screen rather than to the data. The thing worth being precise about is that the window is much bigger than the screen — with the default windowSize of 21 it is about twenty-one screenfuls, ten above and ten below — because the point is not to render as little as possible but to render a bounded amount, with enough already-mounted runway that scrolling never waits for a row to be created.
Intermediate
A list flickers white when scrolled quickly. What would you look at?
Model answer — First, whether someone has lowered windowSize. It is measured in viewports, so a value that reads as conservative — 2 or 3 — cuts the runway from ten screens to one, and a fast flick outruns it. Second, the cost of renderItem, because the list is mounting around two hundred rows at the defaults rather than the ten you can see, so anything expensive in a row is multiplied by two hundred: a date formatted per render, a style object rebuilt, an unmemoised subtree. Third, whether getItemLayout can be supplied — if rows are a fixed height that removes all the measurement work and makes the offsets exact rather than approximate. I would reach for the row before the list props, because the props rebalance work and fixing the row removes it.
Advanced
Why does React Native's list not recycle views the way RecyclerView does?
Model answer — Because recycling assumes you hand the framework a pool of view objects and it hands them back to you to refill, which is an imperative contract that does not fit a declarative renderer. In React you describe what should exist for a given slice of data and the reconciler decides what to create and destroy; there is no dequeue call to make and no requirement that rows be interchangeable shells. So VirtualizedList windows rather than recycles: it renders a slice around the viewport, expands that slice incrementally at maxToRenderPerBatch cells per pass, and lets React unmount whatever falls outside. The cost is that mounting and unmounting real components is more expensive than refilling a pooled cell, which is exactly the gap the faster community list libraries close by putting recycling back underneath React. The benefit is that rows can be arbitrarily heterogeneous with no extra machinery, and that the whole thing is ordinary React with no lifecycle to learn.
Company lens — how the same topic gets asked
micro1Practical: build a long list and justify every prop you set on it.
GooglePushes on the algorithm: the render window, the units, and the incremental fill.
MetaIts own implementation — expect windowing versus recycling and why the trade went that way.
AmazonOperational: diagnosing blank space and memory in a list on a real device.

Hands-on

Hands-on

Rebuild the windowing. Implement the OrDefault helpers with the real constants; renderWindow, computing the overscan range exactly as computeWindowedRenderLimits does — the length is (windowSize - 1) viewports, split by the leadFactor of 0.5, clamped at zero and at the last item; expandTowards, which grows a current range toward a target by at most maxToRenderPerBatch new cells; defaultKeyExtractor with its key, then id, then index fallback; and itemLayout, the fixed-height shortcut. The checks below run either way — a stub scores zero, so a passing run means the behaviour is real.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Summary

  • Virtualization renders a bounded amount, not a minimal one. At the defaults a list showing ten rows has about 211 mounted.
  • windowSize is measured in viewports, so its default of 21 means ten screenfuls above, one visible, ten below.
  • onEndReachedThreshold uses the same unit — 2 is two screen-heights of runway, not two rows.
  • overscanLength is (windowSize - 1) * visibleLength, split evenly by a hard-coded leadFactor of 0.5.
  • The velocity-biased window was tried and removed, and the dead expression is still there beside the constant that replaced it.
  • The window is filled incrementally, maxToRenderPerBatch new cells per pass every updateCellsBatchingPeriod milliseconds.
  • initialNumToRender governs first paint, which is the most expensive moment in the list's life.
  • Anything expensive in renderItem costs two hundred times, not ten — fix the row before tuning the list.
  • The default key extractor falls back to the array index when an item has no key or id, silently.
  • React Native windows rather than recycles, because a declarative renderer has no pool to dequeue from.

In the next chapter, Keys, Memoisation and Re-renders, we stay inside the row: why a re-render that is free on a laptop is visible on a phone, what React.memo actually compares, and how a single inline arrow in a parent can defeat every optimisation below it.

Editorial reflection

The number that stayed with me is not 211, it is the ratio: twenty-one to one. A system whose entire purpose is described as "render less" is, by design, rendering twenty-one times what it displays. That only looks contradictory if you think the goal is minimisation, and it is not — the goal is a bound that does not grow with the data, and within that bound the framework spends generously, because the thing being bought is the absence of blank space. I think this is the most common category error in performance work generally: treating a resource as something to minimise when it is really something to bound. Memory that is constant at two hundred rows is fine whether the list has a thousand items or a million; memory that grows with the data is a crash waiting for a sufficiently enthusiastic user. The defaults encode that judgement, and the fact that lowering windowSize is the most common "optimisation" people reach for suggests the judgement is not widely shared — which is usually a sign that the number needs explaining rather than changing.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.