Search IOCombats

Search challenges, guides, questions and articles

System DesignTopic 5 of 15AdvancedAug 3, 2026

Virtualized List (100k+ items)

Render a list of 100,000 rows at a constant DOM cost by mapping scroll offset to a visible index range, with the measurement machinery variable heights demand.

frontend-system-designpractice-problemvirtualization

The Problem

Render a list of 100,000 items - a log viewer, a contact list, a message archive - that scrolls smoothly, responds to input immediately, and does not crash a mid-range phone.

The naive implementation is items.map(renderRow). It works at 100 items, is sluggish at 5,000, and is unusable at 100,000. The technique that fixes it is windowing: keep only the rows that are actually visible in the DOM, while making the scroll container behave exactly as though all of them were there. The idea takes one sentence; the engineering is entirely in the details, and the hardest of them is that real rows do not all have the same height.

Requirements

Functional

  • Scroll through 100,000 rows with a scrollbar that reflects the full list length.
  • Rows of varying, content-dependent height.
  • Programmatic scroll-to-index (deep links, search results, "jump to unread").
  • Keyboard navigation and screen reader announcement of true list position.
  • Rows may change height after mounting - an image loads, text expands.

Non-functional

  • DOM node count constant regardless of list length.
  • Scrolling holds the display refresh rate; no long tasks caused by scrolling.
  • Memory flat as the user scrolls; no growth over a long session.
  • Initial render under 100ms.

Why the DOM Cannot Hold 100,000 Rows

Worth stating precisely, because "it is slow" is not an analysis. Four costs compound:

Node creation. 100,000 rows is never 100,000 nodes. A row with an avatar, two lines of text and a button is 6-10 elements, so you are asking the browser to create most of a million nodes in one synchronous task. The tab freezes for seconds.

Style and layout. Recalculating styles and computing layout are proportional to tree size. Once the tree is huge, any invalidation is expensive - a hover rule, a class toggle, a web font finishing loading, a window resize. The cost is not paid once at render; it is paid on every subsequent change.

Memory. Every node carries a computed style and layout box. Add decoded images, event listeners and framework fibers, and a long list is hundreds of megabytes. On mobile, that is a tab crash.

Framework bookkeeping. A reconciler diffing 100,000 elements does real work even when nothing changed.

All four land on the main thread, which is also the thread that handles input. That is why the symptom users report is not "the list is slow" but "the page stopped responding" - the long-task and INP problem described in Performance Engineering, in its most extreme form.

Windowing

Windowing virtualized list

The insight is that a scroll container's height and its contents are independent. The browser needs the container to be 100,000 rows tall for the scrollbar to be right. It does not need the rows to exist.

Diagram
100%
flowchart TB subgraph DATA["Dataset in memory - 100,000 items (plain array, no DOM)"] D["items[0] ... items[99999]"] end subgraph SCROLLER["Scroll container - height 6,000,000px (100k x 60px)"] direction TB SPACER_TOP["Spacer / offset: 2,400,000px<br/>(rows 0-39,999 - NOT in the DOM)"] subgraph WINDOW["Rendered slice - the only real DOM nodes"] OVER_TOP["overscan rows 39,997-39,999<br/>mounted, above the fold"] VIS["VISIBLE rows 40,000-40,012<br/>(scrollTop 2,400,000, viewport 800px)"] OVER_BOT["overscan rows 40,013-40,015<br/>mounted, below the fold"] end SPACER_BOT["Spacer: 3,599,040px<br/>(rows 40,016-99,999 - NOT in the DOM)"] end D -.->|"slice(39997, 40016)"| WINDOW style DATA fill:#0f172a,stroke:#334155 style SCROLLER fill:#0f172a,stroke:#334155 style SPACER_TOP fill:#1e293b,stroke:#475569 style SPACER_BOT fill:#1e293b,stroke:#475569 style WINDOW fill:#1e3a5f,stroke:#3b82f6 style VIS fill:#1e3f2d,stroke:#22c55e style OVER_TOP fill:#3f2d1e,stroke:#f59e0b style OVER_BOT fill:#3f2d1e,stroke:#f59e0b
visualized byIOCombats

Read the numbers in that diagram: 19 rows exist in the DOM out of 100,000, while the scrollbar and the container's six-million-pixel height are indistinguishable from a fully rendered list. The two spacers carry the weight of 99,981 rows without a single node.

Fixed-height virtualization

With one known row height, the mapping is division:

const ROW_HEIGHT = 60;
const OVERSCAN = 3;

function getVisibleRange(
  scrollTop: number,
  viewportHeight: number,
  itemCount: number,
) {
  const firstVisible = Math.floor(scrollTop / ROW_HEIGHT);
  const visibleCount = Math.ceil(viewportHeight / ROW_HEIGHT);

  return {
    start: Math.max(0, firstVisible - OVERSCAN),
    end: Math.min(itemCount - 1, firstVisible + visibleCount + OVERSCAN),
  };
}
function FixedList({ items }: { items: Item[] }) {
  const [scrollTop, setScrollTop] = useState(0);
  const viewportRef = useRef<HTMLDivElement>(null);
  const viewportHeight = viewportRef.current?.clientHeight ?? 800;

  const { start, end } = getVisibleRange(
    scrollTop,
    viewportHeight,
    items.length,
  );
  const totalHeight = items.length * ROW_HEIGHT;

  return (
    <div
      ref={viewportRef}
      onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
      style={{ height: '100%', overflowY: 'auto' }}>
      {/* Gives the scrollbar the full range. Contains nothing. */}
      <div style={{ height: totalHeight, position: 'relative' }}>
        {items.slice(start, end + 1).map((item, offset) => {
          const index = start + offset;
          return (
            <div
              key={item.id}
              style={{
                position: 'absolute',
                // One transform instead of a top offset keeps this on the
                // compositor rather than invalidating layout per update.
                transform: `translateY(${index * ROW_HEIGHT}px)`,
                height: ROW_HEIGHT,
                width: '100%',
              }}>
              <Row item={item} />
            </div>
          );
        })}
      </div>
    </div>
  );
}

Three details that are easy to get wrong:

  • key={item.id}, never key={index}. With an index key, scrolling reuses the component instance for a different item, so local state - an open menu, an editing field, a checkbox - attaches to the wrong row. This is the single most reported bug in hand-rolled virtual lists.
  • Absolute positioning plus translateY rather than a spacer div, so a scroll update changes one transform per row rather than reflowing a spacer.
  • onScroll is unavoidable here, because you need continuous scroll position rather than a threshold crossing. Intersection Observer is the right tool for pagination (Infinite Scroll) and the wrong one for this. Keep the handler to a single state write - do not read layout inside it, since that forces synchronous layout mid-frame.

Variable Heights

Real rows are not uniform. A message row is one line or twelve. A card with an image is 200px until the image fails and it is 80px. The moment heights vary, the offset-to-index mapping stops being arithmetic and becomes a lookup into information you do not have - because you cannot know a row's height until you have rendered it, which is exactly what you are avoiding.

The resolution is estimate, measure, correct.

const ESTIMATED_ROW_HEIGHT = 80;

class OffsetIndex {
  /** Measured heights by index. Absent = never rendered. */
  private heights = new Map<number, number>();
  /** Cumulative offsets - offsets[i] is the top edge of row i. */
  private offsets: number[] = [];
  /** First index whose offset is no longer trustworthy. */
  private dirtyFrom = 0;

  constructor(private itemCount: number) {}

  private heightAt(index: number) {
    return this.heights.get(index) ?? ESTIMATED_ROW_HEIGHT;
  }

  /** Rebuild cumulative offsets from the first dirty index onward. */
  private reconcile() {
    if (this.dirtyFrom >= this.itemCount) return;

    let runningOffset =
      this.dirtyFrom === 0
        ? 0
        : this.offsets[this.dirtyFrom - 1] + this.heightAt(this.dirtyFrom - 1);

    for (let index = this.dirtyFrom; index < this.itemCount; index += 1) {
      this.offsets[index] = runningOffset;
      runningOffset += this.heightAt(index);
    }

    this.dirtyFrom = this.itemCount;
  }

  setHeight(index: number, height: number) {
    if (this.heights.get(index) === height) return;
    this.heights.set(index, height);
    // Everything after a changed row has moved.
    this.dirtyFrom = Math.min(this.dirtyFrom, index);
  }

  getOffset(index: number) {
    this.reconcile();
    return this.offsets[index] ?? 0;
  }

  getTotalHeight() {
    this.reconcile();
    const last = this.itemCount - 1;
    return last < 0 ? 0 : this.offsets[last] + this.heightAt(last);
  }

  /** Binary search - offsets are sorted ascending by construction. */
  findIndexAtOffset(target: number) {
    this.reconcile();

    let low = 0;
    let high = this.itemCount - 1;

    while (low <= high) {
      const mid = (low + high) >> 1;
      const top = this.offsets[mid];
      const bottom = top + this.heightAt(mid);

      if (target < top) high = mid - 1;
      else if (target >= bottom) low = mid + 1;
      else return mid;
    }

    return Math.max(0, Math.min(this.itemCount - 1, low));
  }
}

Three design choices carry this:

Cumulative offsets, not per-row heights, are what you query. Summing heights on every lookup is O(n) per scroll event, which defeats the purpose. Maintaining the prefix sums makes lookup a binary search at O(log n) - about 17 comparisons for 100,000 rows, cheap enough to run on every scroll event.

Dirty-from tracking. When a row's height changes, only rows after it move, so reconciliation restarts from that index instead of rebuilding the whole array. In practice measurement corrections cluster near the viewport, so most rebuilds touch a small tail. If even that is too much - millions of rows - the next step is a Fenwick tree, giving O(log n) updates as well as lookups. That is real complexity and should be reached for only when measured.

Estimates are load-bearing. Every unmeasured row contributes ESTIMATED_ROW_HEIGHT. A bad estimate produces a scrollbar that visibly rescales as the user scrolls into measured territory. Derive it from real data - the median rendered height of the first screenful is a good runtime estimate, and far better than a guessed constant.

Measuring with ResizeObserver

Rows must report their real height as they mount, and again if they change - an image decoding, text expanding, a font swapping.

function MeasuredRow({ index, item, offsetIndex, onResize }: MeasuredRowProps) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new ResizeObserver((entries) => {
      for (const entry of entries) {
        // borderBoxSize includes padding and border, which is what the layout
        // arithmetic needs. contentRect excludes them and is off by that much.
        const height =
          entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;

        offsetIndex.setHeight(index, height);
      }
      onResize();
    });

    observer.observe(element);
    return () => observer.disconnect();
  }, [index, offsetIndex, onResize]);

  return (
    <div
      ref={ref}
      style={{
        position: 'absolute',
        transform: `translateY(${offsetIndex.getOffset(index)}px)`,
        width: '100%',
      }}>
      <Row item={item} />
    </div>
  );
}

ResizeObserver is the right tool because it fires after layout, in a dedicated callback phase, so reading the size does not force a synchronous reflow the way a getBoundingClientRect in an effect would. It also catches later changes that a one-time measurement misses entirely.

The hazard is a measurement feedback loop: measuring changes offsets, which changes total height, which can change what is rendered, which triggers measurement. Two guards keep it stable - setHeight returns early when the value is unchanged, and height updates are batched into one state commit per frame rather than one per row.

The visible-jump problem

When a row above the viewport is measured taller than estimated, every offset below it shifts down - including the rows the user is looking at, which visibly jump. The fix is scroll compensation: when offsets above the current position change by delta, adjust scrollTop by the same amount so the viewport stays anchored to the same content.

function onOffsetsChangedAbove(delta: number) {
  if (delta === 0 || !viewportRef.current) return;
  // Keep the same content under the user's eyes. Without this, improving a
  // measurement above the fold shoves the reader down the list.
  viewportRef.current.scrollTop += delta;
}

This is also why restoring a position must anchor to an item id, not a pixel offset - the same conclusion reached from a different direction in Infinite Scroll. A pixel offset is only meaningful against a set of measurements you no longer have.

The Scroll Update Path

Diagram
100%
flowchart TB S["scroll event on viewport"] --> RAF{"already scheduled<br/>this frame?"} RAF -->|"yes"| DROP["coalesce - drop this event"] RAF -->|"no"| SCHED["schedule on rAF"] SCHED --> READ["read scrollTop + clientHeight<br/>(one read, cached for the frame)"] READ --> MAP["findIndexAtOffset(scrollTop)<br/>binary search, O(log n)"] MAP --> RANGE["compute end index by walking<br/>offsets until viewport is filled"] RANGE --> BUF["expand by overscan,<br/>clamp to [0, count-1]"] BUF --> CMP{"range changed<br/>vs last frame?"} CMP -->|"no"| NOOP["no render - the common case<br/>on sub-row-height scrolls"] CMP -->|"yes"| PIN["union with pinned indices:<br/>focused row, open row, selected row"] PIN --> RENDER["render slice - React reconciles;<br/>keys are item ids so instances<br/>follow their data"] RENDER --> MEASURE["ResizeObserver fires for new rows"] MEASURE --> UPD["setHeight + mark dirtyFrom"] UPD --> COMP{"offsets above the<br/>viewport changed?"} COMP -->|"yes"| ADJ["scrollTop += delta<br/>(anchor the reader)"] COMP -->|"no"| DONE["done"] ADJ --> DONE style DROP fill:#3f2d1e,stroke:#f59e0b style NOOP fill:#3f2d1e,stroke:#f59e0b style RENDER fill:#1e3a5f,stroke:#3b82f6 style ADJ fill:#1e3f2d,stroke:#22c55e style PIN fill:#1e3f2d,stroke:#22c55e
visualized byIOCombats

Three decisions in that flow are what make it fast rather than merely correct.

Coalesce to one update per frame. Scroll events can arrive faster than frames. Doing the work more than once per frame is waste that the user cannot perceive.

Bail out when the range is unchanged. Most scroll events move less than one row height, so the visible range is identical and the correct action is to render nothing. This single check removes the majority of render work in a slow scroll.

Read layout once per frame. One scrollTop read at the top of the frame, then pure arithmetic. Reading layout repeatedly inside the update is the forced-synchronous-layout trap, and it is worse here than anywhere else because it happens while the compositor is mid-scroll.

Overscan

Overscan is the buffer of extra rows rendered outside the viewport. Without it, fast scrolling shows blank space - the browser scrolls on the compositor thread and can outrun your main-thread render.

The tradeoff is direct:

OverscanBlank space riskCost per update
0High - visible gaps on any fast scrollMinimum
2-5Low for typical scroll speedsSmall
10+Very lowMount/unmount cost dominates

The right number depends on row cost, not row count. Cheap text rows can afford ten. Rows containing an image, a chart or a video should stay near two, because mounting one is expensive enough that a larger buffer trades a rare gap for consistent stutter.

Two refinements. Directional overscan - render more rows in the direction of travel and fewer behind - gets most of the benefit at roughly half the nodes, because a user scrolling down is overwhelmingly likely to keep scrolling down. And content-visibility: auto on rows lets the browser skip rendering work for content it knows is offscreen, which pairs well with a modest overscan; use it with contain-intrinsic-size so skipped rows still contribute a plausible height.

Accessibility and Find-in-Page

Windowing trades DOM completeness for performance, and some browser features depend on DOM completeness. Ignoring this is how a fast list becomes an unusable one.

Screen readers count what exists. A listbox holding 20 of 100,000 options announces "20 items". The fix is to tell the truth explicitly:

<ul role='listbox' aria-label='Contacts' aria-setsize={items.length}>
  {visible.map((item, offset) => (
    <li
      key={item.id}
      role='option'
      aria-posinset={start + offset + 1}
      aria-selected={item.id === selectedId}>
      {item.name}
    </li>
  ))}
</ul>

aria-setsize declares the real total and aria-posinset the real position, so assistive technology reports "item 40,001 of 100,000" from a DOM containing 20 rows.

Focus must not be unmounted. If the focused row scrolls out of the window and gets removed, focus falls back to document.body and the keyboard user loses their place completely. The rendered range must therefore be the union of the visible range and a small set of pinned indices - the focused row, any row with an open menu, rows mid-edit. That is the PIN step in the flow above, and it is not optional.

Keyboard navigation needs scroll-then-focus. Arrowing to a row that is not mounted requires scrolling it into view, waiting for it to render, and only then moving focus:

async function focusRow(index: number) {
  scrollToIndex(index);
  // The row does not exist until after the render triggered by the scroll.
  await new Promise((resolve) => requestAnimationFrame(resolve));
  document.getElementById(`row-${index}`)?.focus();
}

Find-in-page cannot be fixed. Ctrl+F searches rendered text, and 99.98% of your list is not rendered. There is no JavaScript workaround. The honest response is to provide your own search over the underlying data, which also gives you a scroll-to-match path that native find cannot offer for a windowed list. This is the kind of platform-feature tradeoff that belongs in a design discussion rather than being discovered later - the reasoning in Accessibility Architecture applies directly.

Common Interview Follow-Up Questions

"How do you implement scroll-to-index when heights are unknown?" For fixed heights it is scrollTop = index * ROW_HEIGHT. For variable heights the target offset is a guess built from estimates, so it needs iteration: scroll to the estimated offset, let the rows there mount and measure, recompute the offset for the target index, and scroll again - typically converging in two or three passes. Cap the iterations to avoid an infinite loop when a row's height is genuinely unstable, and after the final pass call scrollIntoView on the actual row so the last few pixels are exact. This is also why deep-linking to a row in a variable-height list feels slower than in a fixed-height one, and why some products cache measured heights per user session or ship a server-side height estimate.

"The list is also infinitely paginated. How do the two compose?" Cleanly, and it is the common production case. Virtualization controls what is rendered; pagination controls what is loaded. The virtualiser's window can exceed the loaded data, so the range calculation must clamp to loaded items and render skeleton rows beyond them, with the estimated height used for not-yet-loaded rows so total height is plausible. Trigger the next page when the visible range approaches the end of loaded data - a threshold on the index range, not a sentinel element, since the sentinel may not be mounted. The pagination side, including why cursors are required, is in Infinite Scroll.

"Would you build this yourself?" Fixed-height, single-column, no keyboard requirements - yes, it is about 80 lines and you have seen most of them. Anything with variable heights, measurement, focus preservation, scroll-to-index and sticky rows - use @tanstack/virtual or react-virtuoso. The parts that take longest are precisely the ones invisible in a demo: measurement feedback loops, scroll compensation on offset changes, focus preservation across unmounts, and RTL and writing-mode support. A hand-rolled list typically ships the happy path and then accumulates the same bug reports the libraries fixed years ago.

"Rows contain images. What changes?" Height instability and memory. Reserve space with aspect-ratio or explicit dimensions so a decoding image does not change the row's height and trigger offset reconciliation - the CLS argument from Performance Engineering doing double duty as a virtualization correctness argument. Memory is the second issue: browsers do not always release decoded image data promptly, so a long scroll session accumulates it. Unmounting rows helps, and loading="lazy" inside a virtualised list is largely redundant since rows only mount near the viewport anyway. The placeholder and layout-stability techniques are in Image Gallery with Lazy Loading.

"How do you test a virtualised list?" The offset index is a pure class and deserves the heaviest testing - offsets after a mid-list height change, binary search at both boundaries, total height with a mix of measured and estimated rows. jsdom reports every element as zero-height, so integration tests must stub measurement rather than rely on layout; that is a limitation to design around, not fight. The behaviours worth a real browser are the ones that only exist there: no blank space during a fast programmatic scroll, focus surviving a scroll past the focused row, and aria-setsize reporting the true total. The layering rationale is in Testing Strategy.

Tradeoffs Table

OptionProsConsWhen to Use
Render everythingTrivial, find-in-page works, accessibility free, no measurement codeFreezes on first render, layout cost grows with the tree, mobile memory crashesUp to a few hundred simple rows
Fixed-height windowingOffset mapping is one division, exact scrollbar, tiny implementationEvery row must be the same height; content that varies gets clipped or paddedUniform rows - log lines, tables, simple pickers
Variable-height windowingHandles real content, adapts as rows changeEstimate-measure-correct machinery, shifting scrollbar, scroll compensation neededFeeds, message lists, anything content-driven
Overscan 0-1Fewest nodes, cheapest updatesVisible blank space on fast scrollVery expensive rows, or scroll-locked lists
Overscan 5-10Blank space effectively eliminatedMount and unmount cost dominates each updateCheap text rows, fast-scrolling lists
Unmount far pages, keep heightsMuch simpler than full virtualization, no offset indexBounded rather than constant DOM cost; only helps at scaleA paginated feed that has grown past its DOM budget

Where This Applies

Virtualization is the sharpest illustration of the main-thread budget from Performance Engineering: the same content either freezes the tab or scrolls at 60fps depending only on how much of it exists in the DOM. It is also a direct consequence of the hydration and render costs described in Rendering Architecture - a server-rendered list of 100,000 rows produces megabytes of HTML that must then be hydrated, so long lists are almost always client-windowed regardless of the rest of the app's rendering strategy. And it is where the tradeoff in Accessibility Architecture is most explicit, because aria-setsize, focus pinning and the loss of find-in-page are all consequences of a performance decision rather than oversights.

In this track it is the natural endpoint of Infinite Scroll, which creates the DOM growth this solves. It constrains Drag and Drop, where only mounted rows can be measured. And it is what makes a long image gallery or a table-heavy dashboard widget viable at scale.

Advertisement

Frequently Asked Questions

What actually goes wrong when you render 100,000 rows into the DOM?

Four costs compound. Creating the nodes is a long synchronous task, so the tab freezes for seconds during the initial render - and each row is rarely one node, so 100,000 rows of a modest card is easily a million elements. Style recalculation and layout then scale with that tree, so any change that invalidates layout - a hover rule, a class toggle, a font finishing loading, a window resize - has to walk all of it. Memory grows with every node's computed style and layout box, plus decoded images and event listeners, which is what makes mobile tabs crash. And the framework's own bookkeeping scales too, since a virtual DOM diff over 100,000 elements is expensive even when nothing changed. The user-visible symptom is not a slow list, it is a page that stops responding to input, because all of that work happens on the main thread that also handles clicks.

How does windowing map a scroll offset to a range of visible rows?

For fixed-height rows it is arithmetic - divide the scroll offset by the row height to get the first visible index, divide the viewport height by the row height to get how many fit, and add a small buffer at each end. The rendered rows are then absolutely positioned at index times height, or pushed into place by a single spacer above them, while the scroll container is given a total height of item count times row height so the scrollbar behaves as though everything were present. The scroll container is honest about the full size, the DOM only holds what is visible, and the mapping between the two is one division. Everything difficult about virtualization comes from removing the assumption that every row has the same height.

Why is variable-height virtualization so much harder than fixed-height?

Because the offset-to-index mapping stops being arithmetic and becomes a lookup into data you do not have yet. To know which row sits at offset 40,000 you need the summed heights of every row before it, and you cannot know a row's height until it has been rendered and measured - which is exactly what virtualization avoids doing. The standard resolution is estimate, measure, correct - start from an estimated height for unmeasured rows, keep a running array of measured offsets so lookups become a binary search rather than a scan, and re-measure with a ResizeObserver as rows mount. The consequence is that total height is a moving estimate, so the scrollbar shifts slightly as measurement improves, and any code that restores a scroll position must anchor to an item rather than a pixel offset.

What does overscan do and how should it be tuned?

Overscan renders a few extra rows above and below the visible window so that a scroll reveals already-mounted content instead of blank space. The tradeoff is direct - more overscan means fewer visible gaps but more nodes and more work per scroll update, since every mount and unmount is real render cost. Two to five rows is the usual sweet spot, and the right number depends on row cost rather than row count. Cheap text rows can afford ten; rows containing images or charts should stay near two, because mounting one is expensive enough that a larger buffer costs more in stutter than it saves in blank space. Rendering only in the scroll direction is a refinement that gets most of the benefit at half the nodes, since the user is far more likely to keep scrolling the way they were already going.

What breaks for accessibility and for browser find-in-page when rows are not in the DOM?

Both rely on content existing. Find-in-page cannot match text that is not rendered, so Ctrl+F silently fails for 99% of the list and there is no way to fully fix that from JavaScript - the honest answer is to provide your own search over the data. Screen readers announce list position from the DOM, so a listbox holding 20 of 100,000 options reports itself as having 20 items unless you supply aria-setsize with the true total and aria-posinset on each row. Keyboard navigation also needs care, because arrowing to a row that is not mounted requires scrolling it into view and waiting for it to render before focus can land on it - and if focus is on a row that gets unmounted, focus falls back to the body and the user loses their place entirely, so the focused row must be kept mounted regardless of the visible range.