open atlas
↑ Back to track
React, zero to senior RCT · 05 · 02

Rendering cost and virtualization: the 10,000-row table that renders 30 rows

Render volume kills: a 10k-row table is 300k DOM nodes and a second per keystroke. Virtualization renders only the viewport plus overscan (TanStack Virtual: estimateSize, measureElement, translate); content-visibility is the CSS cousin for static content. Keys must be item ids.

RCT Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The back-office team inherited a transactions table that “worked fine in staging”. Staging had 200 rows; the biggest customer had 11,400. On that account, typing in the filter box cost 1.3 seconds per keystroke, and scrolling stuttered even with the filter untouched. The first response was reflexive: React.memo on the row component, useMemo on the formatter. The commit time dropped from 1.3 s to 1.1 s — memoization helped the React half a little, but the page was still dragging 340,000 DOM nodes around, and every filter change still asked the browser to restyle and re-layout a 90,000-pixel-tall document. The actual fix was admitting that no user ever sees 11,400 rows at once. A virtualizer rendered the 25 rows in the viewport plus a few of overscan; the DOM fell from 340,000 nodes to under 1,500, keystroke commits fell to 18 ms, and scroll went back to native speed. Same data, same row component — two orders of magnitude less work, because the work was never necessary.

Why 10,000 rows is slow twice

A big list hurts you in two separate engines, and memoization only talks to one of them. First, the React cost: an update that touches the list — a filter keystroke, a sort, a websocket tick — re-renders row components. Even a cheap 0.1 ms row times 10,000 is a full second of render phase, and React.memo only helps for rows whose props did not change, which a filter by definition defeats. Second, the browser cost, which exists even when React is idle: a row of eight cells is easily 30+ DOM nodes, so 10k rows is ~300k nodes that consume memory, slow down style recalculation, and make every layout pass touch a document tens of thousands of pixels tall. This is why the Hook team’s memoization shaved 0.2 s and no more — they optimized the React engine while the layout engine was the one drowning.

The structural observation that fixes both: the viewport shows maybe 25 rows. Everything else is rendered, laid out, and painted for nobody. Virtualization (windowing) renders only the visible slice into a container with one tall spacer element preserving the scrollbar geometry, and repositions the slice as the user scrolls. React renders 30 components instead of 10,000; the browser owns 1,500 nodes instead of 300,000. The list becomes O(viewport) instead of O(data).

TanStack Virtual: the mechanics

When you reach for a virtualizer, the first question is: does it force a specific DOM structure on you, or does it stay out of your markup? TanStack Virtual is headless — it owns the math, you own the HTML — which matters the moment you need a custom row layout or a non-standard scroll container.

A headless virtualizer like TanStack Virtual does three jobs: track scroll position, decide which indexes are visible, and tell you where to absolutely position them. You keep full ownership of the markup:

function TransactionList({ items }) {
  const parentRef = useRef(null);
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 44,   // px guess per row — corrected by measurement
    overscan: 5,              // extra rows above/below the viewport
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((v) => (
          <div
            key={items[v.index].id}          // data id — never v.index
            ref={virtualizer.measureElement} // measures real height
            data-index={v.index}
            style={{ position: "absolute", top: 0, left: 0, width: "100%",
                     transform: `translateY(${v.start}px)` }}
          >
            <Row item={items[v.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

The moving parts are worth naming. estimateSize is a guess; for dynamic heights, measureElement observes each mounted row and replaces the guess with reality, shifting the offsets of everything below — a wildly wrong estimate makes the scrollbar jump as measurements come in, so estimate from your real median row. overscan renders a few extra rows beyond the viewport so fast scrolling shows content instead of blank flashes; each unit of overscan is rendering you paid for that may never be seen, so 3–10 is the usual range, not 50. Failure modes to expect: position: sticky headers do not work naturally inside a transform-positioned list (the virtualizer’s own translate breaks the sticky containing block — pin headers outside the scroll container, or use the virtualizer’s sticky-item support); and remote data needs the count from the server before the scrollbar can be honest.

Quiz

In a virtualized list, a developer keys rows with key={v.index} (the virtual index). Rows expand when clicked. The user expands row 3, scrolls 500 rows down — and some unrelated row appears expanded. Why?

content-visibility: the CSS alternative

Sometimes you cannot virtualize — the markup comes from a CMS, the list is semantic content where find-in-page and Ctrl-F matter, or the refactor cost is not worth it. CSS content-visibility: auto tells the browser to skip rendering work (style, layout, paint) for off-screen elements while keeping them in the DOM; pair it with contain-intrinsic-size: auto 44px so skipped elements still reserve estimated space and the scrollbar stays sane. The crucial difference from windowing: the nodes still exist. React still rendered all 10,000 components, memory still holds 300k nodes, and find-in-page still works because the content is real. So content-visibility rescues scrolling and initial layout (the browser engine) but does nothing for update cost (the React engine) — a keystroke that re-renders 10,000 rows is exactly as slow as before. Decision rule: browser-side cost on mostly-static content → content-visibility, one line of CSS; React-side update cost on interactive data → virtualization, a real refactor. The Hook’s table needed the refactor because typing, not just scrolling, was slow.

Quiz

A long article page (thousands of DOM nodes, no interactivity) scrolls badly. A 10k-row live-updating table is slow on every data tick. Which tool fits which problem?

Recall before you leave
  1. 01
    A 10k-row table is slow both while typing in its filter and while scrolling. Explain the two distinct cost centers and why React.memo meaningfully fixes neither.
  2. 02
    Name the three classic operational pitfalls of a windowed list — row heights, overscan, and keys — and what goes wrong in each if handled naively.
Recap

A 10,000-row table is slow twice, in two different engines. React pays O(n) per update: a filter keystroke re-renders the rows, and memoization only rescues rows whose props are identical — which a filter defeats by design. The browser pays O(n) continuously: ~30 nodes per row means ~300,000 DOM nodes that inflate memory, style recalculation, and layout, which is why the Hook team’s React.memo campaign moved 1.3 s to 1.1 s and stopped. Virtualization fixes the volume itself: render only the visible ~25 rows plus a small overscan, absolutely positioned with translateY inside a spacer div whose height (getTotalSize) keeps the scrollbar honest. TanStack Virtual supplies the mechanics — estimateSize as the initial guess, measureElement to replace guesses with real heights for dynamic rows (a bad estimate makes the scrollbar jump as corrections land), overscan of 3–10 to cover fast scrolling without re-buying the volume problem. Two discipline points: key rows by item id, never by virtual index, or component state bleeds onto whatever data scrolls into the recycled slot; and sticky headers need to live outside the transformed rows. When the content is static and semantic — long articles, CMS output — content-visibility: auto with contain-intrinsic-size is the one-line alternative: the browser skips off-screen style, layout and paint while the DOM stays real (find-in-page works), but React’s update cost is untouched, because all 10,000 components still render. Browser-side pain on static content takes the CSS; React-side update pain on live data takes the refactor. Now when you see a list that slows on every keystroke, you will check the node count before you write a single line of React.memo.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.