Skip to content

Pagination and list virtualization

A windowed list trades a flat DOM for rows that exist only as data: search, measure and focus must then be handled by you.

Predict first

Before you read: predict

ExamHall's admin results grid holds all 50,000 attempts of a sitting in memory and renders them with TanStack Virtual's default options, one row per attempt, each row showing the candidate's name. The grid shows its first rows. An admin who knows only a candidate's surname, Okafor, needs that candidate's row, which is the 31,207th. She presses Ctrl+F, types the name, and the browser finds nothing.

Which change brings Okafor's row onto the screen, starting from the name alone?

Pick one

The 30-second version

Window a list only when it is too long to render: mount the rows near the screen, and the DOM stays one size. The rest are data that find-in-page and screen readers miss, so search the data, then scroll to the match's index. Measure rows of unknown height, keep the focused row mounted, and refresh cursor pages in order.

A windowed list trades a flat DOM for rows that exist only as data: search, measure and focus must then be handled by you.

How it works

Step 1 of 13

Window the list. Mount the rows on screen plus an overscan above and below; a row leaving the window is removed or reused.
In the kitchen
  • the screen shows the current lines and the next one fading in, for a short song or a long medley → the mounted window and its overscan (steps 1–2)

Analogy: A karaoke bar's lyrics screen

The song file holds every line; the screen shows a few.

At the karaoke screen In the windowed list
the screen shows the current lines and the next one fading in, for a short song or a long medley the mounted window and its overscan (steps 1–2)
scanning the screen for a word of the last verse finds nothing until the screen gets there find-in-page sees only mounted rows (step 4)
the host jumps to that verse from the song's verse list search the data, then scroll to its index (step 5)
the progress bar counts one screen line per lyric line until a long line wraps to two estimated against measured sizes (steps 6–8)
a finger on a line of the glass points at another verse once the screen moves on a reused node keeps focus but shows another row (step 9)

In real terms: before windowing, count the rows; after windowing, add your own search, measure rows, and keep focus mounted.

Breaks down when: a karaoke screen moves on by itself in time; a windowed list moves only when the reader scrolls, and it builds the rows it shows on demand.

Contrasting case: a printed lyric sheet for one short song, handed over whole: a list of a few hundred rows rendered in full (step 10).

Examples, run for real

4.1 ExamHall EXH-3410 → MR !3710: the results grid at 50,000 attempts

The admin results grid rendered every attempt as a row; at 50,000 attempts the page held 50,000 rows. MR !3710 windows it with TanStack Virtual's default options (480 px of grid, 48 px rows).

EXH-3410 every attempt a row · 50,000 attempts · rows in the DOM 50,000 · list 2,400,000 px tall · browser's find for row 31,207's name: 1 match
MR !3710 windowed            · 5,000 attempts · rows in the DOM 11 · inner box 240,000 px tall
MR !3710 windowed            · 50,000 attempts · rows in the DOM 11 · inner box 2,400,000 px tall · browser's find for row 31,207's name: 0 matches

The window follows the screen (steps 1–2), the inner box keeps the scrollbar true (step 3), and the browser's find no longer reaches a row far from the screen (step 4).

4.2 Proof domain, ticketing: a resale listings refresh

A resale app holds 3 pages of 50 listings, newest first, by cursor. Four listings are posted; the seller taps Refresh.

resale replace the first page only       · requests 1 + 1 by the scroll · reachable 150 of 154 · shown twice 0 · never shown 4 (L-104 … L-101)
resale refetch in order (refreshInOrder) · requests 3 + 1 by the scroll · reachable 154 of 154 · shown twice 0 · never shown 0

Replacing only the first page drops the held listings that the new ones pushed past its end; refetching in order, each page with the cursor the page before it just returned, keeps all 154 (step 12).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 8

ExamHall's proctor console shows the 500 candidates of a sitting as tiles in one scrolling column, each tile 96 px tall with the candidate's name and live status; about 10 fit on screen. Three needs: a proctor can reach any tile with Ctrl+F, every tile stays in the accessibility tree, and the browser renders the contents of only the tiles on or near the screen.

Which rendering meets all three?

Pick one

Question 2 of 8

A ticket-resale app lists 2,000 listings with TanStack Virtual and estimateSize: () => 48. Each row is placed with transform: translateY(start) and has no height of its own. A listing is 48 px tall; a listing with a seller note shows the note under its title, which makes that row 96 px. Buyers say the seller notes are hidden behind the next listing.

Which change shows every note in full, with no gap between listings?

Pick one

Question 3 of 8

A box office's support screen lists 2,000 orders with TanStack Virtual's default options: 48 px rows, about 10 on screen, each with a Refund button. An agent tabs to the Refund button of order 6, then scrolls 300 orders down with her mouse wheel and presses Enter, meaning to refund order 6.

Which change makes that Enter refund order 6?

Pick one

Question 4 of 8

ExamHall's admin results grid loads attempts newest first by the cursor (submittedAt, id), 100 per page, and keeps each page with the cursor it was requested with; the first page's cursor is null. The admin has scrolled through 3 pages, 300 attempts. Then 7 new attempts are submitted, and she presses Refresh, which runs:

The grid shows the rows of pages in order, and scrolling to its end loads the next page with the last page's next cursor.

What can she reach after the refresh, however far down she scrolls?

Code for this question
async function refresh() {  pages = await Promise.all(pages.map((p) => fetchPage(p.cursor)));}
Pick one

Question 5 of 8

fetchPage(cursor) resolves to { rows, next }, and the first page's cursor is null. Work in a file of your own, from refresh.template.mjs, and say your test list aloud before you run the check.

Write refreshInOrder(fetchPage, count): refetch up to count pages from the top, each with the cursor the page before it returned, and resolve to the pages.

Question 6 of 8

A ticket-resale app lists a match's seats with TanStack Virtual in a scroll area 600 px tall, with estimateSize: () => 40 and overscan: 5. For a match with 2,000 seats, the list opens at the top. The final, with 20,000 seats, uses the same list, which also opens at the top.

How do the row nodes in its DOM and the height of its inner box compare with the smaller match's?

Pick one

Question 7 of 8

A ticket-resale app lists 10,000 seats with TanStack Virtual, estimateSize: () => 40 and measureElement. Exactly 120 rows have been measured so far, each at 64 px; no other row has been measured.

What does getTotalSize() return?

Pick one

Question 8 of 8

ExamHall's admin results grid holds 300 attempts in rows, newest submission first by (submittedAt, id), loaded by cursor 100 at a time; attempt A-150 sits in its second page. Then 12 new attempts are submitted, and after them a proctor reopens A-150 and its candidate submits it again, so it is now the newest attempt. The admin presses Refresh, which fetches the first page again (the newest 100) and merges it into rows, the new rows on top. Scrolling to the grid's end loads the page after its last row.

What does the grid show after the refresh, however far down she scrolls?

Pick one

Go deeper

Pro tip
  1. Name windowing's costs in the same breath as the choice: what find-in-page, row heights and focus will need. When: "the table has 50,000 rows, how do you render it?" in a frontend round. Status: SELF; not run.
Source

No item is keyed on a source: every key is executed. The sources support the teaching steps. Valid as of 2026-10-09.

  • web.dev, Virtualize large lists with react-window (the curriculum row's URL): https://web.dev/articles/virtualize-long-lists-react-window · sources/webdev-virtualize.txt. "List virtualization, or "windowing", is the concept of only rendering what is visible to the user" and "This keeps the number of all rendered elements specific to the size of the window" (steps 1–2); "DOM nodes that exit the "window" are recycled, or immediately replaced" (step 9).
  • TanStack Virtual (the curriculum row's URL): https://tanstack.com/virtual/latest · sources/tanstack-virtual.txt. "Virtualization is a coordinate system, not a list component" (step 3).
  • TanStack Virtual, Virtualizer API: https://tanstack.com/virtual/latest/docs/api/virtualizer · sources/tanstack-virtual-api.txt. overscan: "The number of items to render above and below the visible area … The default value is 1" (step 1); estimateSize returns "the actual size (or estimated size if you will be dynamically measuring items with virtualItem.measureElement)" (steps 6–7); rangeExtractor "receives visible range indexes and should return array of indexes to render" (step 9); scrollToIndex (step 5); getTotalSize "will incrementally change if you choose to dynamically measure your elements as they are rendered" (step 8). The runs use @tanstack/virtual-core 3.17.11.
  • MDN, Intersection Observer API (the curriculum row's URL): https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API · sources/mdn-intersection-observer.txt. Among its uses: "Implementing "infinite scrolling" websites, where more and more content is loaded and rendered as you scroll" (step 11).
  • web.dev, content-visibility: https://web.dev/articles/content-visibility · sources/webdev-content-visibility.txt. "the off-screen content remains available in the document object model and therefore, the accessibility tree … This means, that content can be searched for on the page" (step 10).
  • MDN, content-visibility: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/content-visibility · sources/mdn-content-visibility.txt. For auto: "the skipped contents must still be available as normal to user-agent features such as find-in-page, tab order navigation, etc." (step 10).
  • TanStack Query, Infinite Queries: https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries · sources/tanstack-query-infinite.txt. "each group is fetched sequentially, starting from the first one. This ensures that even if the underlying data is mutated, we're not using stale cursors and potentially getting duplicates or skipping records" (step 12).

Checked against the tools and docs as of 2026-10-09.