Why your list jumps when you load older messages
You open a chat in the middle, the user scrolls up, you fetch the previous page and prepend it. The content above the viewport grows, and the whole list lurches down by the height of everything you just inserted. The user loses their place.
On React Native the platform solves this for you. ScrollView has a
maintainVisibleContentPosition prop that records the frame of the first visible
child before a mount commit and shifts contentOffset by the difference
afterwards. It never consults your estimates, so a bad estimate cannot break it.
FlashList turns it on by default.
maintainVisibleContentPosition does not appear anywhere in
react-native-web 0.21.2. On web, every list has to hold its own position, and most do not.
That is the gap this library exists for. It tracks an item and how far its top sits above the viewport, rather than a scroll offset, so measurement and insertion cannot move what you are looking at.
Use it
import { VirtualList } from 'react-native-virtual-list'
<VirtualList
data={messages}
estimatedItemHeight={72}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Message {...item} />}
onStartReached={loadOlder}
/>
When older messages arrive, tell the list how many went on the front. It is explicit
rather than inferred from data growing, because only you know which end changed,
and guessing wrong is what makes a list jump.
ref.current.prepend(50)
ref.current.scrollToIndex(4823, { align: 'center', animated: true })
ref.current.scrollToEnd()
Item positions cannot go out of order
Offsets here are prefix sums over a Fenwick tree, not an array of positions that gets partially rebuilt. Heights are never negative, so the sequence of offsets is non decreasing by construction. There is no table that can fall out of sort order, so the search that decides which rows are visible cannot be misled.
That is not a hypothetical benefit. In Shopify/flash-list#2307, unmeasured items are positioned from a rolling average seeded at 200px. When the rows above finally measure taller, their positions grow but the rows below keep the stale average, the position table steps backwards, and the binary search over it returns an unrelated row. Ask for item 250, render item 333. This repository contains a test that reproduces that mechanism and then shows it cannot occur here.
What is actually tested
| Property | How it is checked |
|---|---|
| Prefix sums and searches are correct | Differential fuzzing against a naive O(n) implementation over 2000 random updates |
| Offsets never step backwards | Measured in adversarial arrival orders |
indexAt inverts offsetOf | Every index across a list of one million |
| The anchored row never moves | 40 random trials, measuring above, below and on the anchor |
| Prepending never moves the anchor | 30 random trials, including buffer reallocation |
| Totals do not drift | Sum of a million Float32 heights equals the tree total exactly |
| Only visible rows mount | 13 render tests through react-test-renderer; 100,000 rows mount fewer than 15 |
56 automated tests, no device needed. Plus a manual pass on a
physical iOS device, and a browser pass on react-native-web where a probe read the real
getBoundingClientRect().top of a tracked row across four consecutive prepends to
2000 rows. Every round the scroll position moved by exactly the amount the content grew.
Cost at scale
| 1M items | 5M items | |
|---|---|---|
| Build | 11 ms | 52 ms |
visibleRange | 0.84 µs | 1.15 µs |
setHeight | 0.33 µs | 0.29 µs |
| Prepend 50 | 0.09 ms | 0.02 ms |
| Index memory | 12 MB | 58 MB |
A frame at 60fps is 16.7 ms. Deciding what is visible at five million items uses about 0.007 percent of it. The index costs 13 bytes per row, which at a million rows is less than the data you are listing.
Where you should not use this
If you are React Native only and FlashList works, use FlashList. It has a team behind it, per type recycling pools, and far more users. This does not try to beat it and will not.
- No recycling. Rows mount and unmount rather than being reused. FlashList is faster on long fast scrolls through uniform rows.
- Android is untested. iOS is covered on real hardware and web in a browser. Android should behave like iOS because both go through the platform's own prop, but that is reasoning rather than evidence.
- Single column vertical only. No masonry, no sticky headers, no columns.
- It cannot make an estimate correct. The position of a row nobody has rendered is unknown. What is guaranteed is that unknown positions stay ordered and consistent, not that they are right.
Release history
| Version | Date | What changed |
|---|---|---|
| 0.1.1 | 17 Aug 2026 | Fixed web prepending, which was broken in 0.1.0. The layout maths was right but the corrected offset was never written back to the scroll view, so web jumped by the full inserted height. Also ships compiled output so bundlers that only treat .jsx as JSX can build it. |
| 0.1.0 | 15 Aug 2026 | First release. |
Issues and pull requests are welcome, particularly a case where the position still moves. A reproduction naming the platform and the sequence of calls is enough.