Understanding List Virtualization - J Lopes
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.
Here is the whole idea in one running component: ten thousand cells in a grid, with only the visible handful in the DOM. Scroll it, then flip to the code tab.
PreviewVue<br>10<br>11<br>12<br>13<br>14<br>15<br>16<br>17<br>18<br>19<br>20<br>21<br>22<br>23<br>24<br>25<br>26<br>27<br>28
script setup lang="ts"><br>import { computed, ref } from 'vue';
const columns = 4;<br>const rowHeight = 48;<br>const viewportHeight = 240;<br>const overscan = 2;<br>const itemCount = 10000;<br>const rowCount = Math.ceil(itemCount / columns);
const scrollTop = ref(0);
const visibleCells = computed(() => {<br>const start = Math.max(0, Math.floor(scrollTop.value / rowHeight) - overscan);<br>const end = Math.min(<br>rowCount,<br>Math.ceil((scrollTop.value + viewportHeight) / rowHeight) + overscan,<br>);<br>const cells = [];<br>for (let i = start * columns; i Math.min(itemCount, end * columns); i++)<br>cells.push(i);<br>return cells;<br>});
const onScroll = (e: Event) => {<br>scrollTop.value = (e.currentTarget as HTMLElement).scrollTop;<br>};<br>script>
template><br>div<br>class="virtual-grid"<br>:style="{ height: `${viewportHeight}px` }"<br>@scroll.passive="onScroll"<br>div class="spacer" :style="{ height: `${rowCount * rowHeight}px` }"><br>div<br>v-for="i in visibleCells"<br>:key="i"<br>class="cell"<br>:style="{<br>width: `${100 / columns}%`,<br>height: `${rowHeight}px`,<br>transform: `translate(${(i % columns) * 100}%, ${Math.floor(i / columns) * rowHeight}px)`,<br>}"<br>{{ i + 1 }}<br>div><br>div><br>div><br>template>
style scoped><br>.virtual-grid {<br>width: 100%;<br>overflow-y: auto;<br>background: var(--muted);
.virtual-grid .spacer {<br>position: relative;
.virtual-grid .cell {<br>box-sizing: border-box;<br>position: absolute;<br>top: 0;<br>left: 0;<br>display: flex;<br>align-items: center;<br>justify-content: center;<br>border-bottom: 1px solid var(--muted-strong);<br>border-right: 1px solid var(--muted-strong);<br>font-size: 14px;<br>color: var(--primary);<br>style>
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...