DEV Community

Cover image for Understanding List Virtualization
J Lopes
J Lopes

Posted on • Originally published at jlopes.eu

Understanding List Virtualization

If you don't know what virtualization is, you probably don't need it.

It essentially renders only the items that need to be visible in the UI. Most lists are short, most grids are small, and the browser renders them without complaint.

I didn't need it either, until I decided to put the entire Unicode table on a page.

The whole idea fits in one running component: ten thousand cells in a grid, with only the visible handful in the DOM. There's a live demo of it in the original post on jlopes.eu; scroll it, then flip to the code tab.

A quick word on what "in the DOM" means, because the rest of this article hangs on it. The DOM is the browser's live model of the page: every div, button, and span is a node in one big tree, and the browser has to lay each node out, paint it, and keep it in memory. A few hundred nodes are nothing. Tens of thousands are a problem, and the problem compounds when they keep changing.

In practice, the Character map

I built a tool called Character map. It scans over 30,000 code points (Unicode's term for one character), loaded once and cached in the browser's built-in database IndexedDB, so the table doesn't re-download on every visit. You browse it through a grid of tiles. The filtered results are capped at 2,000, and I assumed that ceiling would keep it smooth.

**Narrator: It didn't.

The first version rendered every result, so a single filter pass produced 2,000 buttons. Scrolling stuttered, and resizing was worse because the browser had to recalculate the position of every tile in the grid.

Search is debounced at 150ms, meaning it waits until you've paused typing for 150ms before running the filter, and even so, each pause re-ran the filter, threw away the grid, and rebuilt it.

Memory climbed while I typed. Two thousand rows are nothing to a database, but two thousand live elements in a reflowing grid were plenty to kill page performance.

It took me a while to realise that there were two costs involved:

  • One is the number of DOM nodes.
  • The other is how often the existing nodes rebuild.

Virtualization fixes the first and, on its own, does nothing about the second. We'll get to the second thing later.

Lying to the scrollbar

At any moment, the viewport (the visible area of the scroll container) shows a few rows. Everything above and below is off-screen and, as far as the user can tell, doesn't need to exist. So you keep the visible rows in the DOM plus a small buffer, and drop the rest. As you scroll, the top and bottom rows are mounted/unmounted depending on the direction. The user sees a full list, and the browser only cares about a few rows.

The catch is the scrollbar. A scrollbar's length is as big as the content, so if only twenty rows exist, you reach the bottom and ruin the effect. It has to keep lying, or the whole effect feels broken. The lie is a single spacer element that wraps the existing rows, sized to the height the rows would occupy if they all existed at the same time. The rows you mount are placed on top of that spacer with a translateY offset, shifting elements down by a few pixels without breaking the layout.

Which rows to mount now will depend on arithmetic. You always know the scroll position and the row height, so dividing one by the other tells you which row index sits at the top edge of the viewport; add the viewport height, and you have the bottom edge. React called this windowing, and web.dev walks through the same pattern with react-window. The mechanics are identical in Vue.

Stripped of framework, the whole geometry is three numbers:

const spacerHeight = rowCount * rowHeight;

const startRow = Math.floor(scrollTop / rowHeight);
const endRow = Math.ceil((scrollTop + viewportHeight) / rowHeight);

// each mounted row sits at translateY(rowIndex * rowHeight)
Enter fullscreen mode Exit fullscreen mode

Only the rows from startRow to endRow exist. Everything the scrollbar believes about the rest of the content comes from the spacer. And the whole trick is the division. For example: rows are 80px tall, you've scrolled 800px, so row 10 sits at the top edge. Nothing gets searched or measured.

The payoff is that the DOM node count stops tracking the dataset. Twenty rows on screen means twenty-odd rows in the DOM, whether the list holds two thousand entries or two hundred thousand. As far as the layout engine is concerned, the other 199,980 rows are a myth.

The grid is a list of rows in a trench coat

This is where my grid diverged from the tutorials. A virtualiser, the library that handles the windowing bookkeeping for you, thinks in lists: one item per index, stacked vertically, each taking the full width. My tiles sit twelve to a row. Hand the virtualiser the flat array of tiles, and it will earnestly stack 2,000 full-width rows of one glyph each, and every offset it calculates is wrong.

The fix is to change what an item means. Slice the flat list into groups of twelve first, and make each group the virtual item. The virtualiser counts and positions rows, never knowing whether they contain cells; a plain CSS grid lays the tiles out inside each one.

The demo in the original post does all of this by hand: reads scrollTop, divides by the row height, and mounts the window. For the Character map, I used TanStack's useVirtualizer instead, purely for simplicity's sake: someone has to own the scroll listener, the range maths, and the offsets, and I'd rather it wasn't me.

In code, that's one computed and one hook. A computed is Vue's memoised derived value; it recalculates only when something changes. rows does the slicing, and the virtualiser gets told three things: how many rows there are, which element scrolls, and that each row is about 80px tall. Trimmed from the Character map:

const columns = 12;

const rows = computed(() => {
    const out: Char[][] = [];
    for (let i = 0; i < filteredChars.value.length; i += columns)
      out.push(filteredChars.value.slice(i, i + columns));
    return out;
});

const virtualGrid = useVirtualizer(
    computed(() => ({
      count: rows.value.length,
      getScrollElement: () => grid.value,
      estimateSize: () => 80,
      overscan: 5,
    })),
);
Enter fullscreen mode Exit fullscreen mode

The template renders only the visible rows, each with its own grid:

<div ref="grid" class="overflow-y-auto h-[60vh] min-h-80">
    <div :style="{ height: `${virtualGrid.getTotalSize()}px`, position: 'relative' }">
      <div
        v-for="row in virtualGrid.getVirtualItems()"
        :key="row.key"
        class="grid grid-cols-3 lg:grid-cols-12 absolute w-full"
        :style="{ height: `${row.size}px`, transform: `translateY(${row.start}px)` }"
      >
        <button v-for="c in rows[row.index]" :key="c.cp" @click="openDetails(c)">
          {{ c.char }}
        </button>
      </div>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Two details hide in that setup. estimateSize: () => 80 is a promise that every row is 80px tall, so the virtualiser never has to measure anything. And in the real component columns isn't a constant: it's its own computed fed by a breakpoint helper, 12 on desktop and 3 on mobile...

What to do about overscan

Overscan is the number of extra rows past the visible edge that stay mounted, a buffer above and below the window. In the window math from earlier, it widens both edges before they're clamped to the ends of the list:

const startRow = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
const endRow = Math.min(
  rowCount,
  Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
);
Enter fullscreen mode Exit fullscreen mode

CharacterMap keeps 5, and I found that number by getting it wrong first. At 1 or 2, a fast flick on a phone flashed a blank space at the leading edge, so for a frame or two, there was simply nothing there. Raising it gave the browser rows in reserve, but I took care to make sure I wasn't causing slowness by trying to keep the illusion. I added and removed until there was no "blank" issue for my scrolling speed.

Later, getting to the second thing

Windowing fixes node count. It does nothing about how often the surviving rows re-render, and a virtualised grid re-renders a lot. Every scroll frame moves the window. If each visible row re-renders every time, we're just putting a tiny band-aid on a big gash. Both React and Vue tackle the issue differently: how does a row skip re-rendering when nothing about it has changed?

React re-renders by re-running the component and diffing the result against the last one. Normally when a component re-renders, its children re-render too, whether their inputs changed or not. In a virtualised grid, that means a scroll-driven state change can re-invoke every mounted row. React's way out is to mark the components that can skip updates by wrapping them in memo(), comparing each prop with Object.is. This method compares references, and for example two objects with identical contents are still two different objects. In JavaScript, {} === {} is false. memo() compares against the last rendered version, and if it sees a different reference, the row is re-rendered. useMemo and useCallback exist for exactly this, keeping references stable across renders, so memo has something equal to find.

In row form:

const Row = memo(function Row({ chars, onOpen }: RowProps) {
  return (
    <div className="row">
      {chars.map((c) => (
        <button key={c.cp} onClick={() => onOpen(c)}>
          {c.char}
        </button>
      ))}
    </div>
  );
});

// no memo: new in every render, props are never equal
<Row chars={rows[i]} onOpen={(c) => setSelected(c)} />

// memo: the reference is the same one memo saw last time
const openDetails = useCallback((c: Char) => setSelected(c), []);
<Row chars={rows[i]} onOpen={openDetails} />
Enter fullscreen mode Exit fullscreen mode

Vue starts from the other side. A parent updating doesn't drag its children along unless their props changed too. The compiler adds its own layer, knowing which parts of a template can change and which never will. Much of what memo does in React is the default here. When you do want manual control, v-memo freezes a piece of the template until the values you pick change, and computed covers the derived state.

In the Character map, this shows up as a chain of computed(), each link rerunning only when the one before it changes:

const filteredChars = computed(() => {
    if (!data.value) return [];

    const q = debouncedQuery.value.trim().toLowerCase();
    let chars = data.value.characters;

    if (activeGroups.value.length) {
      chars = chars.filter((c) => activeGroups.value.includes(c.group));
    }

    if (q) chars = chars.filter((c) => c.name.toLowerCase().includes(q) /* … */);

    return chars.slice(0, maxResults);
});
Enter fullscreen mode Exit fullscreen mode

rows recomputes only when filteredChars or columns change, and the virtualiser recounts only when rows changes. Scrolling does not affect that chain. It moves the window and leaves the mounted rows alone.

Both frameworks can reach the same end, but React makes you opt out of re-rendering and Vue makes you opt in.

Two ways to break the illusion

Two more things bit me, and neither is related to React or Vue:

  1. The container needs a real height. The fix for me was a fixed height on the scroll container (60vh) plus overflow-y-auto.

  2. Keys have to carry an identity. With a bad key, nodes would get recycled whenever the cell props changed.

My first version keyed tiles by array index, and this didn't give great results... it would re-filter on every keystroke and toggle whenever the node in slot 0 had an id of 0.

<!-- index: look at me, I'm the index 0 now -->
<button v-for="(c, i) in rows[row.index]" :key="i">{{ c.char }}</button>

<!-- identity: oh, hi mark! -->
<button v-for="c in rows[row.index]" :key="c.cp">{{ c.char }}</button>
Enter fullscreen mode Exit fullscreen mode

With each node bound to its own datum, the nodes stopped rerendering. Index keys look fine right up until the previous item moves away, and in a search UI, items tend to move constantly.

Round two: Icon Search

All of this got a second run. Icon Search merges seven icon libraries (Heroicons, Lucide, Phosphor, Solar, Font Awesome, Carbon, Simple Icons) into one searchable set that runs into the tens of thousands.
The tiles now have an inline SVG instead of a text glyph. But with the learnings from the previous tool, the pattern used was nearly untouched. Filtered results are capped at 2,000, chunked into rows, and virtualised by row.

The biggest change was row height: IconSearch tiles carry a label that can wrap to a second line, so rows aren't a uniform height anymore. That matters more than it sounds, because every row's offset is the sum of the heights of all the rows above it. Dynamic height is a two-step process rather than a smarter estimate. estimateSize still runs first, and its guess (88px here) positions rows on the initial render. Then, as each row mounts, a ref callback hands the node to the virtualiser's measureElement, which reads the real rendered height via getBoundingClientRect() and replaces the cached estimate. The data-index attribute tells it which row the node belongs to.

<div
  v-for="row in rowVirtualizer.getVirtualItems()"
  :ref="(el) => rowVirtualizer.measureElement(el as Element)"
  :data-index="row.index"
  class="grid grid-cols-4 lg:grid-cols-12 absolute w-full"
  :style="{ transform: `translateY(${row.start}px)` }"
>
  <!-- tiles for rows[row.index] -->
</div>
Enter fullscreen mode Exit fullscreen mode

Conclusion

In cases like this, it's best to window the DOM so node count stops tracking the dataset. And then slice the grid into rows so the window has something list-shaped to travel over. Tune overscan by getting it wrong in both directions and settling in the middle. And keep the mounted rows idle when nothing about them has changed: React makes you opt out of re-rendering, Vue mostly handles it by default.

Both tools are open source: CharacterMap.vue and IconSearch.vue. This article was originally published at jlopes.eu, where the interactive demo lives.

Top comments (0)