MIKE GUOYNES
← All lessons
Performance
Article · 16 min read · May 2026

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.

What you'll actually learn
  • ·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.
Prerequisites: React, useState, light DOM/render-pipeline familiarity

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.

1
Fetch
How does the server slice the data?
Pagination · offset vs. cursor
2
Trigger
When does the client ask for the next slice?
Infinite scroll
3
Render
How much of it actually goes in the DOM?
Virtualization

"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.

Virtualization. A scroll container with a Top Observer band, a large Visible rows region, and a Bottom Observer band. An arrow labeled 'Only these are rendered' points at the Visible rows.
Virtualization, the headline act. It's only one of three layers.

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.

DEMO 1 · NAIVEAll 50,000 rows in the DOM at once
items.map() straight into the DOM
NOT YET MOUNTED

Mounting 50,000 rows freezes the page for a few seconds.

That freeze is the demo. Then try to scroll the list.

Click the button. Feel the cost.

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 position
The 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.

DEMO 2 · VIRTUALIZEDSame 10,000 items. Only the slice is mounted.
useState(scrollTop) + slice + translateY
21 OF 50,000 ROWS MOUNTED
Activity #0
tx_121d · row 0
#0
Activity #1
tx_197q · row 1
#1
Activity #2
tx_1ge3 · row 2
#2
Activity #3
tx_1nkg · row 3
#3
Activity #4
tx_1uqt · row 4
#4
Activity #5
tx_21x6 · row 5
#5
Activity #6
tx_293j · row 6
#6
Activity #7
tx_2g9w · row 7
#7
Activity #8
tx_2ng9 · row 8
#8
Activity #9
tx_2umm · row 9
#9
Activity #10
tx_31sz · row 10
#10
Activity #11
tx_38zc · row 11
#11
Activity #12
tx_3g5p · row 12
#12
Activity #13
tx_3nc2 · row 13
#13
Activity #14
tx_3uif · row 14
#14
Activity #15
tx_41os · row 15
#15
Activity #16
tx_48v5 · row 16
#16
Activity #17
tx_4g1i · row 17
#17
Activity #18
tx_4n7v · row 18
#18
Activity #19
tx_4ue8 · row 19
#19
Activity #20
tx_51kl · row 20
#20
Scroll a thousand rows. The mounted count stays in the teens.

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?

Data in memory
Unbounded / on the server
Shallow scroll
Just render it.
Infinite scroll alone.
Deep scroll
Virtualization alone.
Infinite scroll + virtualization.

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: translateY positions 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      = 2

What are:

  1. totalHeight
  2. The first visible row before overscan
  3. startIndex
  4. The bottom row before overscan
  5. endIndex

Work it out before you peek. The numbers are clean on purpose.

Reveal answers
  1. totalHeight = 1000 × 50 = 50,000px
  2. First visible row before overscan = floor(500 / 50) = 10
  3. startIndex = max(0, 10 − 2) = 8
  4. Bottom row before overscan = ceil((500 + 300) / 50) = 16
  5. endIndex = min(1000, 16 + 2) = 18

You render rows 8 to 17. Ten rows. The user sees six. Four are overscan.

Companion cheat-sheet
Long lists: the decision guide →

The three-layer model, the decision matrix, and the offset-vs-cursor comparison on one scannable page. Bookmark it.

Further reading

MG
Mike Guoynes
Frontend lead. I write the patterns I wish I'd had earlier.
NewsletterAboutLog in