Yes, you read that right: 8000x faster.
Not "your whole app is now 8000x faster, go buy a yacht" faster. Sadly.
Also yes, I know the timing is funny.
We are living in the era where AI news moves so fast that something like Claude Fable 5 can launch and then get suspended a few days later. People are arguing about AGI-ish models, export controls, agents doing whole projects, all of that.
And here I am talking about making React Native lists faster.
It sounds a bit odd when you put those two things next to each other. I get it.
But I still find this kind of work exciting. Maybe "invention" is too big of a word, but improving a framework used by thousands of apps still feels meaningful to me. Not every useful thing has to look like science fiction. Sometimes it is just removing wasted work from a path almost every mobile app touches.
The win is inside one specific React Native list path: the render-window work that decides which cells should exist right now. In that benchmark, the old path could waste seconds on huge lists, and the new path cuts that down to a few milliseconds.
Most of the time we are on the app side. We use FlatList, SectionList, navigation, animations, state libraries, and all the usual stuff. We complain about performance, try some props, add memo, maybe add getItemLayout, and then move on with life.
But sometimes the real problem is one layer below the app code.
That is what happened here.
I contributed a performance improvement to react/react-native, merged as PR #57210. The change removes unnecessary sticky-header lookup work from the render-mask creation path used by VirtualizedList.
In normal words: React Native was sometimes checking for sticky headers even when the list had no sticky headers.
Classic computer moment.
List: "I have no sticky headers."
React Native: "Cool, let me scan backwards to find one."
The nice part is that this path is shared. So the improvement affects:
VirtualizedListFlatListVirtualizedSectionListSectionList
And yes, regular FlatList usage can benefit even if you are not using sticky headers at all.
What this actually improves
Before going into the code, it is worth saying what kind of performance this is.
The improvement is in the JavaScript work React Native does before deciding which rows should be rendered.
FlatList keeps a render window. While you scroll, jump, mount near an initial index, or move around a large list, React Native needs to decide which cells should be rendered and which parts can stay as spacers.
That decision goes through VirtualizedList._createRenderMask.
So the win is:
- less JS work during list window updates
- less work when jumping near the end of a huge list
- less unnecessary sticky-header logic for lists without sticky headers
- faster decisions before the list updates what should be on screen
In normal app terms, this helps the part of FlatList that decides "what rows should exist right now?"
For very large lists, especially close to the end of the list, that small internal decision can become a real cost.
Removing that cost makes the render-window update path much cheaper.
The thing I noticed
FlatList is one of those components that almost every React Native app uses at some point.
It looks simple from outside:
<FlatList data={items} renderItem={renderItem} />
But under the hood it goes through VirtualizedList.
VirtualizedList does not render the whole data source. If you have 100k rows, it does not put 100k rows on screen like a brave but doomed soldier. It keeps a render window and uses a render mask to decide which cells should exist right now.
That is good.
The problem was inside the render-mask creation step.
React Native also supports sticky headers. Sticky headers are special because the sticky item may be outside the normal viewport, but it still needs to stay rendered because visually it is stuck at the top.
So React Native needs logic like:
"Find the closest sticky header before the viewport and keep it rendered."
That logic is correct.
The annoying part was that the lookup could still run when there were no sticky headers.
The old path
Before this change, VirtualizedList._createRenderMask built a set from stickyHeaderIndices and called the sticky-header helper.
The simplified version looked like this:
const stickyIndicesSet = new Set(props.stickyHeaderIndices);
VirtualizedList._ensureClosestStickyHeader(
props,
stickyIndicesSet,
renderMask,
cellsAroundViewport.first,
);
Then the helper walked backwards from the first visible cell:
for (let itemIdx = cellIdx - 1; itemIdx >= 0; itemIdx--) {
if (stickyIndicesSet.has(itemIdx + stickyOffset)) {
renderMask.addCells({first: itemIdx, last: itemIdx});
break;
}
}
For a small list, this is whatever.
For a very large list, it becomes painful. If the viewport is near the end of a 1 million row list, and there are no sticky headers, the helper can still walk backwards through almost the whole list.
That is basically doing a big search to find nothing.
Not my favorite hobby.

Why FlatList is affected
This part is easy to miss.
You do not need to use VirtualizedList directly. FlatList is built on top of it.
So this kind of code:
<FlatList
data={bigData}
renderItem={renderItem}
/>
still goes through VirtualizedList._createRenderMask.
Before the change, a normal list with no stickyHeaderIndices could still pay for sticky-header lookup work.
After the change, React Native checks whether sticky headers actually exist before doing sticky-header work.
Very advanced technology: check if the thing exists before searching for it.
The change
The first part is the important one for regular non-sticky FlatList usage.
const stickyHeaderIndices = props.stickyHeaderIndices;
if (stickyHeaderIndices != null && stickyHeaderIndices.length > 0) {
VirtualizedList._ensureClosestStickyHeader(
props,
stickyHeaderIndices,
renderMask,
cellsAroundViewport.first,
);
}
If there are no sticky headers, we skip the helper.
The second part improves the sticky-header case too.
Instead of walking backwards through item indices, the helper scans the configured sticky header indices and finds the closest valid sticky header before the viewport.
const stickyOffset = props.ListHeaderComponent ? 1 : 0;
const targetStickyIndex = cellIdx + stickyOffset;
let closestStickyIndex = null;
for (let itemIdx = 0; itemIdx < stickyHeaderIndices.length; itemIdx++) {
const stickyIndex = stickyHeaderIndices[itemIdx];
if (
Number.isInteger(stickyIndex) &&
stickyIndex < targetStickyIndex &&
stickyIndex >= stickyOffset &&
(closestStickyIndex == null || stickyIndex > closestStickyIndex)
) {
closestStickyIndex = stickyIndex;
}
}
if (closestStickyIndex != null) {
const itemIdx = closestStickyIndex - stickyOffset;
renderMask.addCells({first: itemIdx, last: itemIdx});
}
So the cost changes from:
"How far are we from the start of the list?"
to:
"How many sticky headers are configured?"
That is a much better question to ask.
Keeping behavior the same
This is the part that makes small framework changes less small than they look.
You cannot just optimize and hope everything is fine. stickyHeaderIndices may be unsorted. It may contain duplicate values. It may contain invalid values. There may be a ListHeaderComponent, which shifts the index by one.
All of that still needs to work.
The tests cover cases like:
- no
stickyHeaderIndices - empty
stickyHeaderIndices - unsorted sticky indices
- duplicate sticky indices
- negative values
- fractional values
- values after the viewport
ListHeaderComponentoffset handling
That last one is important. Sticky header indices are based on the underlying ScrollView child indices. When a list header exists, item index 0 is not child index 0 anymore.
Small off-by-one bugs love this area. They live there rent free.
Benchmark results
I benchmarked the VirtualizedList._createRenderMask path directly with Fantom Hermes, using 100 samples per case.
This benchmark focuses on the render-window decision work inside VirtualizedList.
So no, this does not mean every FlatList in every app suddenly scrolls 8000x faster. I wish. I would already be unbearable about it.
It means this specific internal list-windowing path got massively cheaper. And when the benchmark goes from seconds of wasted work to a few milliseconds, that is still a very real win.
Simulator proof
I also made a small bare React Native app to compare both implementations on iOS Simulator.
Same app. Same 1m row FlatList. Same no sticky headers setup.
The only difference is the installed VirtualizedList implementation. These are real simulator recordings inside the phone frames, not static mockup screenshots. I left the video controls visible on purpose, and the cursor pulse marks the benchmark button tap.
The benchmark and behavior tests are in the merged React Native PR too:
In the simulator demo, the old path grows with list size. For 1k render-window updates, it looks like this:
50k rows: about1.92 stotal (1.9196 ms / call)500k rows: about18.78 stotal (18.78 ms / call)1m rows: about38.40 stotal (38.40 ms / call)
With the optimized path, the same demo stays basically flat:
50k rows: about3.74 mstotal (0.0037 ms / call)500k rows: about3.67 mstotal (0.0037 ms / call)1m rows: about3.73 mstotal (0.0037 ms / call)
Per-call values are below 1 second, so they are shown in milliseconds. For 1,000 render-mask updates, values above 1 second are shown in seconds.
| Case | One update before | One update after | Speedup | 1,000 updates before | 1,000 updates after |
|---|---|---|---|---|---|
| 100k rows, no sticky headers | 2.664 ms | 0.0034 ms | 780x | 2.66 s | 3.42 ms |
| 100k rows, empty sticky headers | 2.632 ms | 0.0033 ms | 800x | 2.63 s | 3.29 ms |
| 100k rows, one top sticky header | 2.705 ms | 0.0054 ms | 499x | 2.71 s | 5.42 ms |
| 250k rows, no sticky headers | 6.543 ms | 0.0035 ms | 1,892x | 6.54 s | 3.46 ms |
| 250k rows, empty sticky headers | 6.579 ms | 0.0033 ms | 2,024x | 6.58 s | 3.25 ms |
| 250k rows, one top sticky header | 6.796 ms | 0.0053 ms | 1,294x | 6.80 s | 5.25 ms |
| 500k rows, no sticky headers | 13.173 ms | 0.0033 ms | 4,002x | 13.17 s | 3.29 ms |
| 500k rows, empty sticky headers | 13.073 ms | 0.0033 ms | 3,997x | 13.07 s | 3.27 ms |
| 500k rows, one top sticky header | 13.444 ms | 0.0054 ms | 2,511x | 13.44 s | 5.35 ms |
| 750k rows, no sticky headers | 19.524 ms | 0.0033 ms | 6,008x | 19.52 s | 3.25 ms |
| 750k rows, empty sticky headers | 19.572 ms | 0.0033 ms | 6,022x | 19.57 s | 3.25 ms |
| 750k rows, one top sticky header | 20.304 ms | 0.0054 ms | 3,778x | 20.30 s | 5.37 ms |
| 1m rows, no sticky headers | 26.190 ms | 0.0033 ms | 8,058x | 26.19 s | 3.25 ms |
| 1m rows, empty sticky headers | 26.039 ms | 0.0033 ms | 8,012x | 26.04 s | 3.25 ms |
| 1m rows, one top sticky header | 26.855 ms | 0.0053 ms | 5,075x | 26.86 s | 5.29 ms |
The 1,000 update column makes the difference easier to feel.
At 1 million rows, the old no-sticky path spends about 26 seconds in this helper for 1,000 render-mask updates.
After the change, the same case is around 3.25 ms.
That is time removed from a shared list path before React Native decides what cells should be rendered.
Test plan
I wanted this to be boring in the best way: same behavior, less work.
So the contribution included behavior tests and benchmark coverage.
The main commands were:
yarn jest packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js packages/react-native/Libraries/Lists/__tests__/FlatList-test.js --runInBand
yarn fantom packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js packages/react-native/Libraries/Lists/__tests__/SectionList-itest.js --runInBand
yarn flow check
yarn fantom --benchmarks packages/react-native/Libraries/Lists/__tests__/VirtualizedList-stickyHeaders-benchmark-itest.js --runInBand
The PR changed three files:
- the
VirtualizedListimplementation - focused
VirtualizedListtests - a Fantom benchmark for the sticky-header render-mask path
It was imported through Meta's process and merged into react/react-native.
Final thoughts
What I like about this contribution is that app developers do not need to do anything.
No new prop.
No migration.
No workaround.
If an app uses a large FlatList without sticky headers, React Native can now avoid sticky-header lookup work that was unnecessary in the first place.
That is my favorite kind of framework improvement.
Small surface area. Clear benchmark. No API change.
And a common path becomes cheaper for everyone.