Virtualization vs. Infinite Scroll, and How to Build Them
Virtualization, infinite scroll, pagination.
Three fixes for one slow feed. Not the same fix. Not even close.
- ·Why a long list chokes the browser even when your React code is fine.
- ·The three-layer model: fetch, trigger, render. Which technique owns each layer.
- ·How to build a virtualized list in plain React. No library required.
- ·When to reach for virtualization, infinite scroll, or both at once.
The lag
You ship a social feed. Fast load. No complaints.
Six months later, you open it and wait.
The feed has thousands of items now. You scroll and it stutters. You click and it hangs.
The code didn't change. What happened?
How do I make it feel snappy?
You ask around. You Google it. You get three answers.
Virtualize the list. Add infinite scroll. Paginate the API.
All three are good advice. None of them are the same advice.
Most teams pick one and move on. Wrong layer fixed, same slow feed.
These three aren't competitors. They don't even touch the same layer.
The mental model: three layers
Data starts in a database. It ends on a screen. Three problems live between them.
"Virtualization vs. infinite scroll" is a category error. One is a render strategy. The other is a fetch trigger. Asking which is better is like asking whether a steering wheel beats an engine.
Bottom of the stack first.
Layer 1: Fetch. How the server slices the data
Your feed has a million rows. The server is never going to send a million rows. It sends a page at a time. The only real question is how it picks the page.
The obvious way is to count. Skip the first 40, give me the next 20.
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;That's offset pagination. It works. It also breaks in exactly two ways, and both get worse as the app grows.
The first is speed. OFFSET 40 is cheap. OFFSET 100000 is not. The database walks all 100,000 rows and discards the first 100,000. Deep pages get slower, linearly. By deep pages, the database can spend most of the query doing work the user will never see.
The second problem is worse. It's a correctness bug. It shows up exactly when you care.
Picture it. User loads page one: posts 1 to 20. While they're reading, three new posts land at the top. They scroll. Client asks for the next 20, skip 20. But the list shifted. The posts at 18, 19, and 20 are now at 21, 22, 23. The user sees them again. A delete shifts the other way. Three posts gone. They'll never know.
Offset pagination assumes the list holds still. A live feed never holds still.
Cursor pagination fixes this by refusing to count. Instead of "skip 20," it says "give me 20 items after this exact one."
SELECT * FROM posts
WHERE created_at < $cursor
ORDER BY created_at DESC
LIMIT 20;The cursor is a pointer to a real row. Add or delete posts above it and the pointer doesn't move. Same row. Correct page. Fast at any depth because WHERE created_at < $cursor is an indexed lookup, not a count-and-discard.
The tradeoff is real. You give up random access. No "jump to page 50." No total count. For a numbered-pages UI, offset is the right call. For a feed, you never wanted page 50. You wanted the next 20.
Infinite scroll on offset pagination is a bug waiting for traffic. If the list can change under the user, the cursor isn't an optimization. It's the fix.
Layer 2: Trigger. When to ask for more
Layer 1 decided how the server slices. Layer 2 decides when the client asks for the next slice. Three options. You already know them all.
- Numbered pages. The user clicks "2." Honest and bookmarkable. It also makes people click, which on a feed they won't.
- A "Load more" button. One tap, no surprises. Great when the user is hunting for one specific thing.
- Infinite scroll. The next page loads itself as the user nears the bottom. Best for feeds, where people graze instead of hunt.
Infinite scroll sounds like it needs scroll math. It doesn't. Don't write that. One browser API: IntersectionObserver.
Drop an invisible element at the end of the list. Call it a sentinel. Ask the browser to tell you when it enters the viewport. When it does, fetch the next page.
function useInfiniteScroll(onReachEnd) {
const sentinelRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) onReachEnd();
});
const el = sentinelRef.current;
if (el) observer.observe(el);
return () => observer.disconnect();
}, [onReachEnd]);
return sentinelRef;
}
// <div ref={sentinelRef} /> sits at the end of the list.No scroll listener. No math on every pixel. The browser watches and calls you once.
Layer 3: Render. What actually goes in the DOM
Layers 1 and 2 got data onto the page. Layer 3 is a different problem. The server has nothing to do with it.
Even with perfect pagination, the DOM can still get too big.
Infinite scroll appends. User scrolls for ten minutes. Page after page loads. Every row stays mounted. After a long session you don't have 20 rows. You have 10,000.
React is fine. Your code is fine. The browser is not.
The setup
Map over items. Render a row each. The whiteboard version.
function Feed({ items }) {
return (
<div className="feed">
{items.map((item) => (
<Row key={item.id} item={item} />
))}
</div>
);
}50,000 items. 50,000 nodes.
Every scroll asks the compositor to walk a tree the size of a small phone book. Every resize triggers layout on all of it. Every theme change repaints all of it.
The code is fine. The math isn't.
Watch it choke
Click the button. Feel the freeze. Then scroll the list.
Mounting 50,000 rows freezes the page for a few seconds.
That freeze is the demo. Then try to scroll the list.
Wait. Scroll feels fine. Is this even a problem?
Probably. And this is the part nobody explains.
Modern browsers cheat on scroll. Once rows are laid out and painted, the browser rasterizes them into GPU-backed tiles. Scrolling becomes texture math on the GPU. No re-layout. No re-paint.
That's why a 50,000-row list scrolls smooth on a fast Mac. The expensive work already happened. You paid for it during that mount freeze you just felt.
Browsers cheat on scroll. They don't cheat on mount, memory, or reconciliation. Every state change still diffs 50,000 rows. Every resize lays them all out. On a 4G phone, that first mount is a three-second freeze. A low-end Android just kills the tab.
The real problem
You confused your data with your view.
The data is 50,000 items. The view is whatever fits on screen. Maybe sixteen rows, plus a few above and below so a fast scroll doesn't flash white. Everything else is work nobody asked for.
The fix is four moves
Virtualization is four moves. Do them in order and the problem is just arithmetic.
Pixels → indexes → slice → position.
Read the scroll position. Turn it into row numbers. Cut the array to those rows. Push the slice into place. Each move is one or two lines.
Move 1: Give the container its full height
The list has to be the right height even though most of it won't exist. The scrollbar needs something to measure. Give it a spacer the size of the full list and never render into most of it.
const totalHeight = items.length * itemHeight;50,000 rows at 48px is 2,400,000 pixels of fake height. The scrollbar believes it. That's the point. The scrollbar lies. The rendered rows tell the truth.
Then you read where the user actually is.
const [scrollTop, setScrollTop] = useState(0);
const handleScroll = (e) => {
setScrollTop(e.currentTarget.scrollTop);
};scrollTop is the only input virtualization needs. Next: turn those pixels into row numbers.
Move 2: Figure out which rows are on screen
You have the pixel offset. You want two row numbers: the first row to render and the last. The first one is a divide and a floor.
const firstVisible = Math.floor(scrollTop / itemHeight);At scrollTop = 960 and itemHeight = 48, that's row 20. Math.floor because a half-visible row still counts.
You don't start rendering at row 20. Start a few rows earlier. A fast scroll shouldn't outrun the render and flash white. That buffer is overscan.
const startIndex = Math.max(
0,
Math.floor(scrollTop / itemHeight) - overscan,
);Math.max(0, ...) keeps the index from going negative at the top.
The last row is the same idea at the other edge. Bottom of the viewport sits at scrollTop + height. Divide, then Math.ceil because a partial row at the bottom still has to render. Add overscan. Clamp to array length so you don't run off the end.
const endIndex = Math.min(
items.length,
Math.ceil((scrollTop + height) / itemHeight) + overscan,
);Four lines of arithmetic, and pixels became indexes. At scrollTop = 960 you're looking at rows 15 to 36. Not 50,000.
Move 3: Only render those rows
This is the move that does the work.
const visibleItems = items.slice(startIndex, endIndex);Instead of items.map(...), you visibleItems.map(...). The render tree drops from 50,000 nodes to twenty. Everything virtualization promises is on this one line. The other three moves exist to compute its two arguments.
Move 4: Push the rows to where they'd naturally sit
There's a bug hiding in Move 3. Your slice starts at row 15, but React renders it at the top of the container. Row 15 is wearing row 0's position. You push it down by the height of the rows you skipped.
const offsetY = startIndex * itemHeight;
// inside the JSX:
transform: `translateY(${offsetY}px)`,Row 15 belongs at y=720 inside that fake 2,400,000px list. translateY puts it there. translateY runs on the compositor. Same thread as scroll. No layout round trip. No reflow. The position is free.
The whole loop
User scrolls
→ browser updates scrollTop
→ convert scrollTop to row indexes
→ slice the array
→ render only that slice
→ translateY into positionThe scrollbar is fake. The visible rows are real. translateY connects them.
Now assemble it
Every move above is a line or two. Stack them and you have a virtualized list.
function VirtualList({ items, itemHeight = 56, height = 320 }) {
const [scrollTop, setScrollTop] = useState(0);
const overscan = 5;
const totalHeight = items.length * itemHeight;
const startIndex = Math.max(
0,
Math.floor(scrollTop / itemHeight) - overscan,
);
const endIndex = Math.min(
items.length,
Math.ceil((scrollTop + height) / itemHeight) + overscan,
);
const visibleItems = items.slice(startIndex, endIndex);
const offsetY = startIndex * itemHeight;
return (
<div
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
style={{ height, overflowY: "auto", position: "relative" }}
>
{/* Spacer keeps the scrollbar honest */}
<div style={{ height: totalHeight }} />
{/* Only the visible slice gets rendered */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${offsetY}px)`,
}}
>
{visibleItems.map((item, i) => (
<Row key={startIndex + i} item={item} />
))}
</div>
</div>
);
}Same data. Same scrollbar. Two dozen DOM nodes at any moment.
Overscan and variable heights, briefly
Overscan is the buffer above and below the viewport. Three to five rows is plenty. It's the reason a fast scroll doesn't flash white. Twenty rows of overscan is slow virtualization wearing a disguise.
Everything above assumes a fixed row height. Variable height is its own lesson. If rows differ by tens of pixels, estimate and correct as you measure. If they differ by hundreds, reach for @tanstack/react-virtual. That's the exact problem it was built for.
So which do you actually need?
Three layers. Two questions.
Is the data already in memory? Does the user scroll deep?
Rules of thumb:
- Under ~100 rows. Render it and move on. Virtualization is overkill.
- A big array already in memory. A 50,000-row table you loaded once: virtualize it. There's nothing to infinitely scroll. You already have the data.
- Unbounded data, shallow browsing. Infinite scroll. Don't reach for virtualization until the DOM actually hurts.
- Unbounded data, long sessions or a live feed. Both. And it's not optional.
Combining them: the trap
That last cell is the most common one in production. A real feed fetches forever and scrolls forever. You need Layer 2 and Layer 3 working together.
They compose. Infinite scroll grows the array. Virtualization renders a slice of it. One trap.
Remember Layer 2's sentinel? The invisible element at the end of the list. In a virtualized list, the end of the list isn't rendered. The sentinel isn't in the DOM. It can't intersect anything. Your infinite scroll silently stops firing.
Don't watch a DOM node. Watch the math. You already compute endIndex on every scroll. When it gets within a screen or two of items.length, fetch the next page.
// The sentinel is gone. Trigger off the index math instead.
useEffect(() => {
const rowsLeft = items.length - endIndex;
if (rowsLeft < 20 && !isFetching) {
fetchNextPage();
}
}, [endIndex, items.length, isFetching]);Notice the buffer of 20. Fire the fetch when the user is a page away from the end, not when they hit it. Otherwise they scroll into a blank strip and watch a spinner. Overscan hides a fast scroll. Prefetching hides a slow network. You want both.
The law
It was never one question. It was three.
Fetch, trigger, render. Pagination, infinite scroll, virtualization. When a feed is slow, don't ask which one to bolt on. Ask which layer is actually bleeding. Usually it's the one nobody looked at.
Takeaways
- Three layers, three jobs. Pagination slices, infinite scroll triggers, virtualization renders.
- Infinite scroll on offset pagination duplicates and drops rows. Use a cursor.
- Virtualization is four moves: pixels, indexes, slice, position. The slice is the one that does the work.
transform: translateYpositions the slice for free. No layout cost.- Combine infinite scroll and virtualization by triggering off
endIndex, not a DOM sentinel. - Under ~100 rows, skip all of it.
Challenge
Compute the virtualization indexes by hand. Given:
items.length = 1000
itemHeight = 50
height = 300
scrollTop = 500
overscan = 2What are:
totalHeight- The first visible row before overscan
startIndex- The bottom row before overscan
endIndex
Work it out before you peek. The numbers are clean on purpose.
Reveal answers▾
totalHeight= 1000 × 50 = 50,000px- First visible row before overscan =
floor(500 / 50)= 10 startIndex=max(0, 10 − 2)= 8- Bottom row before overscan =
ceil((500 + 300) / 50)= 16 endIndex=min(1000, 16 + 2)= 18
You render rows 8 to 17. Ten rows. The user sees six. Four are overscan.
The three-layer model, the decision matrix, and the offset-vs-cursor comparison on one scannable page. Bookmark it.