Scenario Debugging

Rendering 10,000 Rows: Virtualization, Pagination and Web Workers

Windowing, content-visibility, rendering in small chunks, and moving sort and filter work off the main thread.

Lesson 0715 min read#lists#virtualization#performance

On this page

  1. Where the time goes
  2. The options
  3. How virtualization works
  4. content-visibility for long pages
  5. Chunked rendering
  6. Heavy computation off the main thread
  7. Scenario: a dashboard with 50k rows freezes when sorted. Fix it.
  8. Pagination vs infinite scroll vs virtualization: choosing
  9. Common mistakes
  10. Interview questions
  11. Key takeaways
  12. What's next

In one line: the browser is slow at holding 10,000 rows of DOM, not at sorting 10,000 rows of data. The fix has three parts. Draw only the rows the user can see (virtualization). Fetch only the data they need (server-side pagination and filtering). Keep heavy calculations off the main thread.

Key terms used in this lesson:

  • DOM (Document Object Model) is the browser's tree of page elements. Each tag on the page becomes a node (an object in that tree).
  • Main thread is the one thread in the browser that runs your JavaScript, calculates layout, draws the page and handles clicks. If it is busy, the page freezes.
  • Virtualization (windowing) is a technique that draws only the rows inside the visible window of a long list and reuses them while you scroll.
  • Pagination is splitting data into pages (for example 50 rows each) and loading one page at a time.

"Our table freezes with large data" is a classic problem. A strong answer first says where the time really goes. Then it gives options and explains the trade-offs.

Where the time goes

Take a table with 10,000 rows × 8 columns:

  • That is 80,000 cells, plus the elements inside the cells and the row wrappers. In total this is often well over 100,000 DOM nodes.
  • React must create 10,000 row components when the table first shows. On every update that re-renders them, it must also compare them with the last render (this is called reconciliation).
  • The browser must run style, layout and paint for all those nodes. Style works out which CSS rules apply. Layout works out the size and position of each box. Paint draws the pixels.
  • Memory use grows with the number of nodes.

Sorting 10,000 items in JavaScript is cheap: on a typical laptop, about 5 ms for numbers and 10 to 30 ms for text compared with a locale (measure it on your own data). The page freezes after you click a sort header because of re-rendering 10,000 rows and the layout work after it. On a mid-range phone this can take seconds.

Check the Performance panel (the DevTools tool that records what the browser does over time) before you choose a fix. Suppose the Scripting time for the sort is small, but Rendering/Layout/Paint after it is huge. Then the problem is DOM size. If the sort or filter function (the comparator, which decides which of two items comes first) itself is slow, make that faster instead (below).

The options

OptionIdeaProsCons
Server-side paginationFetch and show one page (say 50 rows) at a timeSmallest data and DOM; works with any dataset size; simpleThe user clicks page buttons instead of scrolling; more requests
Infinite scroll (load more)Add the next page when the user gets near the end of the listFeels naturalThe DOM keeps growing, so it still slows down in the end; hard to reach the footer; the user can lose their place when going back
Virtualization / windowingDraw only the visible rows (plus a few extra above and below, called overscan). Reuse them as the user scrollsSmooth scrolling over very large lists; the DOM size stays the sameMore complex; costs for accessibility and find-in-page; rows with different heights are harder
content-visibility: autoA CSS property that tells the browser to skip layout and paint for sections that are off-screenOne line of CSS; the DOM stays as it isThe nodes still exist (memory, React work); needs size hints
Chunked renderingShow the list in batches over time (requestIdleCallback: a browser function that runs your code when the browser is idle; startTransition: a React function that marks an update as low priority)The page stays responsive while content appearsContent appears bit by bit; the total work is the same
Server-side sort and filterThe database sorts and filters; you send only the resultsScales to millions of rowsNetwork round trips; needs indexes (a database index is a sorted lookup structure that makes searches fast; Backend, Lesson 07)
Web WorkerMove heavy sorting, filtering or parsing off the main thread. A Web Worker is a script that runs on a separate background thread, so it cannot freeze the pageThe main thread stays freeYou must copy or share the data; some extra complexity

Rule of thumb:

  • Up to a few hundred rows: show them all.
  • Thousands of rows: virtualise or paginate.
  • Tens of thousands or more (or data that is costly to send): do the work on the server with pagination, filters and sorting. Virtualise the page of results if you still need to.

Difference in one line: pagination loads fewer rows from the server; virtualization may still hold all rows in memory but draws only a few of them.

How virtualization works

A virtualised list has a scrollable box with a fixed total height, so the scrollbar looks right. It draws only the items that fall inside the visible window:

TEXT
total height = itemCount × rowHeight           (a "spacer" gives the scrollbar its size)first visible index = floor(scrollTop / rowHeight)last visible index  = first + ceil(viewportHeight / rowHeight)render items [first - overscan … last + overscan], each positioned absolutely at index × rowHeight

When the user scrolls, the indexes change. React re-renders a small, fixed number of rows (about 30) and reuses the DOM nodes. The DOM stays the same size whether you have 1,000 or 1,000,000 items.

In the formulas, scrollTop is how far the box is scrolled (in pixels) and viewportHeight is the height of the visible box. Use a library instead of writing this yourself. Good choices are TanStack Virtual, react-window and react-virtuoso (it handles rows with different heights and sticky headers).

JavaScript
import { useVirtualizer } from "@tanstack/react-virtual";function VirtualList({ rows }: { rows: Row[] }) {  const parentRef = useRef<HTMLDivElement>(null);  const virtualizer = useVirtualizer({    count: rows.length,    getScrollElement: () => parentRef.current,    estimateSize: () => 44,         // estimated row height in px    overscan: 8,                    // extra rows rendered above/below for smooth scrolling  });  return (    <div ref={parentRef} style={{ height: 600, overflow: "auto" }}>      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>        {virtualizer.getVirtualItems().map((v) => (          <div            key={rows[v.index].id}            style={{ position: "absolute", top: 0, left: 0, width: "100%", height: v.size, transform: `translateY(${v.start}px)` }}          >            <RowView row={rows[v.index]} />          </div>        ))}      </div>    </div>  );}

Things to get right:

  • Use stable keys (row.id), not the index. A key is a special prop that lets React match each item to the same item in the last render.
  • Wrap the row component in memo and give it stable props. Rows re-render as you scroll (Lesson 03). (memo is a React function that makes a component skip re-rendering when its props did not change. "Stable" means the same value, or the same object or function reference, as before.)
  • Fixed heights are simplest and fastest. Rows with different heights need measuring (measureElement), which costs more.
  • Do not read layout values inside each row while rendering (for example getBoundingClientRect()), because each read can force the browser to redo layout.
  • Position rows with transform (translateY(...)). Moving an element with transform does not trigger layout. The browser only has to move the finished picture.

The accessibility cost of virtualization

Virtualization changes what exists in the DOM. This has side effects. Name them in an interview:

  • Screen readers (tools that read the page aloud) only see the rows that are drawn. ARIA is a set of HTML attributes that give extra meaning to assistive tools. Add aria-rowcount (the total number of rows) on the table and aria-rowindex (the row's position) on each row. Use proper role="grid" or role="table" meaning. Make sure focus and keyboard moves can go beyond the drawn window (arrow keys, Page Down, Home/End).
  • Browser find-in-page (Ctrl+F) will not find rows that are off-screen. Offer a search inside your app.
  • Focus management (keeping track of which element the keyboard is on): the focused row may scroll out of the window and be removed. Then focus is lost. Keep the focused row drawn, or put focus back.
  • Printing and "select all" only affect the rows that are drawn.
  • Anchor links and scroll restoration (going back to the old scroll position) need extra code.

Some reports must be fully searchable and printable. For these, pagination may be the more accessible choice. Add virtualization only if one page is still very large.

content-visibility for long pages

CSS
.section {  content-visibility: auto;                 /* skip rendering work while off-screen */  contain-intrinsic-size: auto 600px;       /* reserve estimated space so the scrollbar doesn't jump */}

contain-intrinsic-size gives the skipped section an estimated size (auto makes the browser remember the real size after it has been drawn). This works well for long articles, feeds and lists of cards. It lowers the layout and paint cost. But the nodes still exist, and React still renders them. So it cannot replace virtualization for very large lists.

Chunked rendering

Sometimes you must make a large list quickly but cannot virtualise it. Examples are a print view or an export preview. Then draw the list in slices (chunks), so the page stays responsive. Note that Safari does not support requestIdleCallback. Use a setTimeout fallback if you must support it.

JavaScript
function ChunkedList({ items }: { items: Item[] }) {  const [count, setCount] = useState(100);  useEffect(() => {    if (count >= items.length) return;    const id = requestIdleCallback(() => setCount((c) => Math.min(c + 200, items.length)), { timeout: 200 });    return () => cancelIdleCallback(id);  }, [count, items.length]);  return <>{items.slice(0, count).map((it) => <ItemRow key={it.id} item={it} />)}</>;}

The first paint is fast. The rest appears over the next frames. The total work is the same, but the page still reacts to input. Wrapping updates in startTransition helps in a different way: React treats them as low priority and can pause the render, so urgent updates such as typing can go first. The DOM still ends up with every node, so chunking is only a stop-gap for lists you cannot virtualise.

Heavy computation off the main thread

Sometimes the sort or filter itself is slow. This happens with tens of thousands of rows and expensive comparators. Then:

  1. Make it cheaper first.
    • Work out sort keys once (a lowercase name, a timestamp number). Do not work them out again inside the comparator.
    • For text that people read, use Intl.Collator (a built-in object that compares text by language rules, such as accents and numbers inside text). Create it once and reuse its compare function. A plain < comparison is faster but puts accented letters in the wrong place. Modern engines make localeCompare fast too, so measure before you change it.
    • Build a Map (a key-value lookup that finds an item at once) or an index for lookups. Do not call find() (which scans the whole array) inside loops.
    • Debounce filtering while the user types. (Debounce means wait until the user pauses before you run the work.)
  2. Move it to a Web Worker so the main thread can keep drawing:
JavaScript
// table.worker.tsconst collator = new Intl.Collator("en");let rows: Row[] = [];self.onmessage = (e: MessageEvent<{ type: "load"; rows: Row[] } | { type: "query"; sort?: string; filter?: string; offset: number; limit: number }>) => {  if (e.data.type === "load") { rows = e.data.rows; return; }  const { sort, filter, offset, limit } = e.data;  let out = filter ? rows.filter((r) => r.searchText.includes(filter)) : rows;  if (sort) out = [...out].sort((a, b) => collator.compare(String(a[sort as keyof Row]), String(b[sort as keyof Row])));  (self as unknown as Worker).postMessage({ total: out.length, page: out.slice(offset, offset + limit) });};

The main thread sends the query. It gets back only the visible page, never the whole 50,000-row result. (Comlink is a library that makes worker calls feel like normal async functions.) Sending data to a worker copies it (the "structured clone" algorithm), so a lot of data has a cost. So load the data once and send small queries after that. 3. Move it to the server when the dataset is large or shared. Let PostgreSQL sort and filter with indexes, and return one page. Use cursor pagination (the request says "give me 50 rows after this last item", instead of "skip 5,000 rows", which gets slower as the offset grows). See Practical Design Problems, Lesson 03.

Scenario: a dashboard with 50k rows freezes when sorted. Fix it.

  1. Measure. Open the Performance panel and click the header. Is the cost in the sort (Scripting) or in rendering and layout? It is usually rendering 50,000 rows.
  2. Reduce the DOM. Virtualise the table (or paginate), so only about 40 rows are drawn.
  3. Make the update cheap. Memoise the row components, and give them stable props and keys. A sort then reuses nodes and does not rebuild them.
  4. Speed up the sort. Use precomputed keys, and Intl.Collator when the text order must follow language rules. Do not sort on every render (use useMemo with [rows, sortKey]). useMemo is a React hook that memoizes (remembers) a value. Memoization means saving the result of a calculation, so React reuses it and works it out again only when its inputs change.
  5. Move the work if it is still slow. Run sort and filter in a Web Worker, or on the server for large datasets.
  6. Keep input responsive. Use useDeferredValue or startTransition for filter inputs, and debounce. (useDeferredValue is a React hook that gives you a copy of a value that updates a moment later, so React can show the typing first and the slow list after.)
  7. Measure again. Check interaction delay (INP: Interaction to Next Paint, how long the page takes to respond after a click, tap or key press), memory and the DOM node count. Add a test with a realistic amount of data.

Pagination vs infinite scroll vs virtualization: choosing

NeedChoose
Huge dataset, searchable, pages you can share by link, accessibility is very importantServer-side pagination (with filters and sorting on the server)
A feed that users skim (social, notifications)Infinite scroll with a limit, or windowing for long sessions
A large table or log viewer where users scroll all the timeVirtualization (often on top of chunks from a paginated server)
A long page that mostly does not changecontent-visibility

Common mistakes

  1. Blaming the sort when the real cost is drawing the rows.
  2. Infinite scroll without windowing, so the DOM grows without limit.
  3. Virtualising but still re-rendering every row on each scroll (no memo, unstable keys).
  4. Ignoring accessibility (no aria-rowcount, broken keyboard navigation) and find-in-page.
  5. Loading all rows into the browser to sort them there, when the database could do it.
  6. Rows with different heights and no measuring, which makes the scrollbar jump.
  7. Tuning on a developer laptop with 5,000 rows, instead of production data volume on a mid-range device.

Interview questions

How does list virtualization work?

It keeps a scroll box with a spacer as tall as the full list. It draws only the rows that are in or near the viewport. It works these out from the scroll position and the row height, and it positions them absolutely. When the user scrolls, it changes which indexes are drawn. It reuses a small, fixed number of DOM nodes. So the DOM size and the render cost stay the same, however long the list is.

Scenario: a dashboard with 50k rows freezes when sorted. Fix it.

First I profile to confirm that drawing the rows is the cost. Then I virtualise or paginate so that few rows are drawn. I memoise the rows and give them stable keys. I make the sort cheaper or precompute it. If the calculation is heavy, I move it to a Web Worker or the server. I use deferred values for typing. At the end I measure INP, DOM size and memory again.

Pagination vs infinite scroll vs virtualization?

Pagination keeps the DOM small, gives links you can share, and is good for accessibility. It works well with server-side filtering. Infinite scroll feels smooth, but the DOM grows unless you add windowing. Virtualization keeps the DOM the same size for continuous scrolling over very large lists. But it needs care for accessibility and find-in-page. For large searchable data, I choose server-side pagination. I add virtualization for long single pages.

What does virtualization cost in accessibility?

Screen readers and find-in-page only see the rows that are drawn. So I must give the total count and the position of each row with aria-rowcount and aria-rowindex. I must make sure keyboard moves and focus still work when rows are removed. I should offer a search in the app. Printing and select-all only affect the drawn rows. In some cases, pagination is the more accessible choice.

Why is the DOM size the real problem, not the data size?

Each row makes many nodes. The browser must style, lay out, paint and store them in memory. React must also compare them. Sorting or filtering an array of 50,000 objects is cheap in comparison. Re-rendering 50,000 rows is not. If you reduce the number of nodes, you get the biggest gain.

When would you use a Web Worker for table data?

I use one when sorting, filtering, parsing or summing large data is itself heavy on the CPU. On the main thread it would block input and drawing. The worker holds the dataset, answers queries, and returns only the visible page. For very large or shared data, the server is usually better.

What does content-visibility: auto do and when is it enough?

It lets the browser skip layout and paint for parts that are off-screen, until they come near the viewport. You add contain-intrinsic-size to reserve space. It is a cheap win for long pages and lists of cards. But the nodes and the React rendering still exist. So for very large lists you still need virtualization.

Key takeaways

  • The bottleneck is drawing DOM nodes, not sorting data. Confirm it with the Performance panel.
  • Reduce the rows first (server-side pagination and filters). Then virtualise. Then memoise the rows with stable keys.
  • Make the calculation cheaper first (precomputed keys, a reused Intl.Collator, indexes). Only then move it to a Web Worker or the server.
  • Virtualization has costs for accessibility and find-in-page. Add ARIA row meaning and keyboard support, or choose pagination.
  • Use useDeferredValue or debounce so typing stays responsive. Test with production-sized data on slowed-down hardware.

What's next

Lesson 08: Layout Shift and Janky Interactions. These are the last two "feels bad" problems: content that jumps while loading, and clicks that lag.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Related series