Virtual rendering for massive lists
by Brian Simon ()
Got 50,000 rows in a table? Your browser will hate you. Traditional DOM rendering chokes on big lists, but virtual rendering fixes that by rendering only what’s actually visible on screen.
Here’s what we’re building: a virtual scroller that handles 100,000 items with variable heights:
100,000 items, but only 10-15 are actually in the DOM at any time.
The items have different heights, and the scroller automatically measures and adjusts as you scroll. Let’s build this from scratch.
The tradeoffs
You render 15-20 rows instead of thousands. Initial load gets fast, and scrolling stays smooth no matter how big the dataset gets.
What nobody mentions is everything you give up. The browser has been quietly doing a lot of work for you, and once the rows aren’t in the DOM it stops.
Control+F is the first thing to go. Users can’t find text that isn’t rendered, so you end up building your own search that filters the data and scrolls to the match.
Screen readers are the bigger problem. They navigate real DOM elements, and you’re now lying to them about what exists, so ARIA attributes, focus states, and keyboard navigation all become yours to manage:
<template>
<div
role="listbox"
:aria-label="`Virtual list with ${items.length} items`"
:aria-rowcount="items.length"
:aria-setsize="items.length"
>
<div
v-for="virtualItem in visibleItems"
:key="virtualItem.index"
role="option"
:aria-posinset="virtualItem.index + 1"
:aria-setsize="items.length"
:aria-selected="selectedIndex === virtualItem.index"
>
<slot :item="virtualItem.item" :index="virtualItem.index" />
</div>
</div>
</template>Selection and focus get trickier for the same reason. The focused item might not exist yet:
const selectedItems = ref(new Set<number>());
const focusedIndex = ref<number | null>(null);
const handleKeyNavigation = (event: KeyboardEvent) => {
if (event.key === 'ArrowDown') {
focusedIndex.value = Math.min(
(focusedIndex.value ?? -1) + 1,
items.length - 1
);
scrollToIndex(focusedIndex.value);
}
};And little things you never wrote code for, like scroll position restoration, stop happening on their own:
const saveScrollPosition = () => {
sessionStorage.setItem('virtualList-scroll', scrollTop.value.toString());
};
const restoreScrollPosition = () => {
const saved = sessionStorage.getItem('virtualList-scroll');
if (saved) {
container.scrollTop = parseInt(saved, 10);
}
};All of it is manageable. It’s just a lot more than “only render what’s visible” makes it sound.
But why?
Say you’re building a log viewer. Developers need to scroll through thousands of entries to find patterns or track down bugs. Pagination would ruin the experience. You need continuous context.
The naive approach is to render everything:
<template>
<div class="log-viewer">
<div v-for="log in logs" class="log-entry">
<span class="timestamp">{{ log.timestamp }}</span>
<span class="level">{{ log.level }}</span>
<span class="message">{{ log.message }}</span>
</div>
</div>
</template>With 50,000+ log entries, this falls over. 50k DOM nodes eat RAM for breakfast, your UI freezes while creating them, scrolling gets janky, and even clicking buttons feels sluggish.
Users can only see maybe 20 items at once, so why render 50,000? Virtual rendering fakes a massive list by only rendering what’s visible.
Handling dynamic heights
Fixed-height virtual rendering is straightforward. Item 100 goes at position 100 × itemHeight. But real applications sometimes have non-uniform heights. Social media feeds have posts that vary from single lines to paragraphs with images. Comment threads mix short replies with code blocks. Search results have snippets of varying length.
The preceding demo handles 100,000 items with completely variable heights. Let’s build it.
Building the dynamic height virtual scroller
Step 1: understand the data
Instead of simple multiplication, we need to track each item’s actual height and offset:
interface ItemMeasurement {
height: number // Actual measured height
offset: number // Distance from top of virtual list
}
// Example data:
// Item 0: { height: 60, offset: 0 }
// Item 1: { height: 120, offset: 60 }
// Item 2: { height: 40, offset: 180 }
// Item 3: { height: 200, offset: 220 }The offset represents the cumulative distance from the top of the virtual list. This is what we’ll use for positioning with the top CSS value.
Step 2: the prefix sum problem
Each item’s offset is the sum of all heights before it:
offset[0] = 0
offset[1] = height[0]
offset[2] = height[0] + height[1]
offset[3] = height[0] + height[1] + height[2]
...
offset[i] = height[0] + height[1] + ... + height[i-1]This is a classic prefix sum problem. Fenwick trees (also called binary indexed trees) are a good fit here, since both updates and queries are O(log n). When an item’s height changes, we only update a few nodes in the tree rather than recalculating all subsequent offsets.
Why not just recalculate offsets when heights change? Because updating item 5’s height means recalculating offsets for items 6, 7, 8… all the way to 100,000. That’s O(n) per update, which is way too slow.
Fenwick trees store partial sums cleverly so we only update a few strategic positions:
Let’s see what happens when we update different positions in an 8-item list:
Update position 1: affects positions 1, 2, 4, 8
Update position 2: affects positions 2, 4, 8
Update position 3: affects positions 3, 4, 8
Update position 4: affects positions 4, 8
Update position 5: affects positions 5, 6, 8
Update position 6: affects positions 6, 8
Update position 7: affects positions 7, 8
Update position 8: affects position 8Notice the pattern? Each update affects at most 3 or 4 positions. Let’s look at position 5:
- From 5, jump to 6 (jump size: +1)
- From 6, jump to 8 (jump size: +2)
- Done!
But why those specific jumps? That’s where binary comes in.
Quick binary refresher
Every number can be written in binary (1s and 0s). Each position represents a power of 2:
Position: ... 8 4 2 1
... ↓ ↓ ↓ ↓
5 in binary: 0 1 0 1 = 4 + 1
6 in binary: 0 1 1 0 = 4 + 2
7 in binary: 0 1 1 1 = 4 + 2 + 1
8 in binary: 1 0 0 0 = 8The “lowest set bit” just means: the rightmost 1 in the binary representation, or equivalently, the smallest power of 2 in the number.
5 = 0101 → rightmost 1 is in the "1" position → lowest set bit = 1
6 = 0110 → rightmost 1 is in the "2" position → lowest set bit = 2
7 = 0111 → rightmost 1 is in the "1" position → lowest set bit = 1
8 = 1000 → rightmost 1 is in the "8" position → lowest set bit = 8Back to our jump sizes from position 5:
- 5 (binary: 0101) → lowest set bit = 1 → jump +1 → land on 6
- 6 (binary: 0110) → lowest set bit = 2 → jump +2 → land on 8
- 8 (binary: 1000) → lowest set bit = 8 → jump +8 → land on 16 (done for our 8-item list)
The jump size IS the lowest set bit. That’s the whole trick.
index & (-index)
This operation extracts the lowest set bit. Let’s see it work for 6:
6 in binary: 0110
-6 in binary: 1010 (flip bits and add 1)
6 & -6: 0010 (only the rightmost 1 survives)
Result: 2Why does this work? Negating a number in binary (two’s complement) flips everything, and when you AND them together, only the lowest set bit position remains. In other words, it extracts that rightmost 1.
So instead of updating 99,999 positions, we only jump through O(log n) positions. For position 5 in a 100,000-item list:
- Jump from 5 → 6 → 8 → 16 → 32 → 64 → 128 → … → eventually past 100,000
- That’s only ~17 jumps instead of 99,995 updates!
The same trick works backwards for querying (subtract the lowest set bit instead of adding it).
Step 3: implement the Fenwick tree
Now we’ll implement a Fenwick tree to efficiently compute offsets on demand.
First, we need a Fenwick tree implementation:
class FenwickTree {
private tree: number[];
private size: number;
constructor(size: number) {
this.size = size;
this.tree = new Array(size + 1).fill(0); // 1-indexed
}
/**
* Update value at index (add delta to the value)
*/
update(index: number, delta: number): void {
index++; // Convert to 1-indexed
while (index <= this.size) {
this.tree[index] += delta;
index += index & (-index); // Add lowest set bit
}
}
/**
* Query prefix sum from 0 to index (exclusive)
*/
query(index: number): number {
let sum = 0;
while (index > 0) {
sum += this.tree[index];
index -= index & (-index); // Remove lowest set bit
}
return sum;
}
/**
* Initialize tree from array of values
*/
initialize(values: number[]): void {
this.tree.fill(0);
for (let i = 0; i < values.length; i++) {
this.update(i, values[i]);
}
}
}Now we can use it to track heights:
const heightTree = ref<FenwickTree>();
const itemHeights = ref<number[]>([]);
// Initialize with estimated heights
const initializeCache = () => {
itemHeights.value = new Array(props.items.length).fill(props.estimatedItemHeight);
heightTree.value = new FenwickTree(props.items.length);
heightTree.value.initialize(itemHeights.value);
};
// Watch for prop changes and reinitialize
// When items are added/removed or the estimated height changes,
// we need to rebuild the Fenwick tree from scratch
watch(
() => [props.items.length, props.estimatedItemHeight] as const,
initializeCache,
{ immediate: true }
);
const getOffset = (index: number): number => {
return heightTree.value?.query(index) ?? 0;
};
const updateItemHeight = (index: number, newHeight: number) => {
if (newHeight === 0) return; // Ignore zero heights during measurement
const currentHeight = itemHeights.value[index];
// Ignore tiny changes
if (Math.abs(currentHeight - newHeight) < 1) {
return;
}
const heightDiff = newHeight - currentHeight;
itemHeights.value[index] = newHeight;
// Update the Fenwick tree
heightTree.value?.update(index, heightDiff);
// Adjust scroll position when scrolling up to prevent jumping
if (scrollDirection.value === 'up' && container.value) {
container.value.scrollTop += heightDiff;
}
};Step 4: ResizeObserver
We need to detect when items change size:
<template>
<div
v-for="virtualItem in visibleItems"
:key="virtualItem.index"
:ref="el => observeItem(el, virtualItem.index)"
:style="{
position: 'absolute',
top: virtualItem.offset + 'px',
width: '100%'
}"
>
<slot :item="virtualItem.item" :index="virtualItem.index" />
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const resizeObserver = ref<ResizeObserver>();
const observedElements = new Map<number, HTMLElement>();
const observeItem = (element: HTMLElement | null, index: number) => {
if (!element) return;
observedElements.set(index, element);
resizeObserver.value?.observe(element);
};
const handleResize = (entries: ResizeObserverEntry[]) => {
entries.forEach(entry => {
// Find which item this element corresponds to
const index = findIndexForElement(entry.target as HTMLElement);
if (index !== -1) {
const newHeight = entry.borderBoxSize[0].blockSize;
updateItemHeight(index, newHeight);
}
});
};
const findIndexForElement = (element: HTMLElement): number => {
for (const [index, el] of observedElements.entries()) {
if (el === element) return index;
}
return -1;
};
onMounted(() => {
resizeObserver.value = new ResizeObserver(handleResize);
});
onUnmounted(() => {
resizeObserver.value?.disconnect();
});
</script>Step 5: binary search
With dynamic heights, we can’t just divide to find the start index. We need to search through our offset cache.
Finding the first visible item when scrolled to position 50,000px in a list of 100,000 items? Linear search means checking items 0, 1, 2, 3… up to 100,000. Binary search? ~17 operations.
Binary search implementation
const findStartIndex = (scrollTop: number): number => {
if (!itemHeights.value.length) return 0;
let left = 0;
let right = itemHeights.value.length - 1;
let result = 0;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const offset = getOffset(mid);
const height = itemHeights.value[mid];
// Check if this item is visible at scrollTop
if (offset <= scrollTop && offset + height > scrollTop) {
return mid; // Found the exact item
}
if (offset < scrollTop) {
result = mid + 1; // Item is above viewport, search right
left = mid + 1;
} else {
right = mid - 1; // Item is below viewport, search left
}
}
return Math.min(result, itemHeights.value.length - 1);
};Binary search is O(log n), and each iteration queries the Fenwick tree which is also O(log n), giving us O(log² n) total, or about 289 operations for 100,000 items. Still way better than linear search.
Visualizing binary search
Searching for first visible item at scrollTop = 5000px:
Step 1: Check middle (item 50,000)
offset: 2,500,000px > 5000px → search left half
Step 2: Check middle of left half (item 25,000)
offset: 1,250,000px > 5000px → search left half
Step 3: Check middle (item 12,500)
offset: 625,000px > 5000px → search left half
...continues until we find the item with offset ≤ 5000pxStep 6: put it all together
Here’s the full implementation:
<template>
<div
ref="container"
class="virtual-list"
:style="{ height: containerHeight + 'px' }"
@scroll="onScroll"
>
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<div
v-for="virtualItem in visibleItems"
:key="virtualItem.index"
:ref="el => observeItem(el, virtualItem.index)"
:aria-posinset="virtualItem.index + 1"
:aria-setsize="props.items.length"
:style="{
position: 'absolute',
top: virtualItem.offset + 'px',
width: '100%'
}"
>
<slot :item="virtualItem.item" :index="virtualItem.index" />
</div>
</div>
</div>
</template>
<script setup lang="ts" generic="T">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
class FenwickTree {
private tree: number[];
private size: number;
constructor(size: number) {
this.size = size;
this.tree = new Array(size + 1).fill(0);
}
update(index: number, delta: number): void {
index++;
while (index <= this.size) {
this.tree[index] += delta;
index += index & (-index);
}
}
query(index: number): number {
let sum = 0;
while (index > 0) {
sum += this.tree[index];
index -= index & (-index);
}
return sum;
}
initialize(values: number[]): void {
this.tree.fill(0);
for (let i = 0; i < values.length; i++) {
this.update(i, values[i]);
}
}
}
interface Props<T> {
items: T[];
estimatedItemHeight: number;
containerHeight: number;
}
interface VirtualItem<T> {
index: number;
item: T;
offset: number;
}
const props = defineProps<Props<T>>();
const container = ref<HTMLElement>();
const scrollTop = ref(0);
const scrollDirection = ref<'down' | 'up'>('down');
const resizeObserver = ref<ResizeObserver>();
const observedElements = new Map<number, HTMLElement>();
const heightTree = ref<FenwickTree>();
const itemHeights = ref<number[]>([]);
const initializeCache = () => {
itemHeights.value = new Array(props.items.length).fill(props.estimatedItemHeight);
heightTree.value = new FenwickTree(props.items.length);
heightTree.value.initialize(itemHeights.value);
};
// Watch for prop changes and reinitialize
// When items are added/removed or the estimated height changes,
// we need to rebuild the Fenwick tree from scratch
watch(
() => [props.items.length, props.estimatedItemHeight] as const,
initializeCache,
{ immediate: true }
);
// Get offset for an item
const getOffset = (index: number): number => {
return heightTree.value.query(index);
};
// Binary search for start index
const findStartIndex = (scrollTop: number): number => {
if (!itemHeights.value.length) return 0;
let left = 0;
let right = itemHeights.value.length - 1;
let result = 0;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const offset = getOffset(mid);
const height = itemHeights.value[mid];
if (offset <= scrollTop && offset + height > scrollTop) {
return mid;
}
if (offset < scrollTop) {
result = mid + 1;
left = mid + 1;
} else {
right = mid - 1;
}
}
return Math.min(result, itemHeights.value.length - 1);
};
const visibleItems = computed(() => {
if (!itemHeights.value.length) return [];
const startIndex = findStartIndex(scrollTop.value);
const visible: VirtualItem<T>[] = [];
const viewportBottom = scrollTop.value + props.containerHeight;
const bufferStart = Math.max(0, startIndex - 3);
for (let i = bufferStart; i < itemHeights.value.length; i++) {
const offset = getOffset(i);
if (offset > viewportBottom + (props.estimatedItemHeight * 3)) {
break;
}
visible.push({
index: i,
item: props.items[i],
offset: offset
});
}
return visible;
});
const totalHeight = computed(() => {
return getOffset(itemHeights.value.length);
});
const updateItemHeight = (index: number, newHeight: number) => {
if (newHeight === 0) return; // Ignore zero heights
const currentHeight = itemHeights.value[index];
// Ignore tiny changes
if (Math.abs(currentHeight - newHeight) < 1) {
return;
}
const heightDiff = newHeight - currentHeight;
itemHeights.value[index] = newHeight;
heightTree.value?.update(index, heightDiff);
// Adjust scroll position when scrolling up to prevent jumping
if (scrollDirection.value === 'up' && container.value) {
container.value.scrollTop += heightDiff;
}
};
// ResizeObserver setup
const observeItem = (element: HTMLElement | null, index: number) => {
if (!element) return;
observedElements.set(index, element);
resizeObserver.value?.observe(element);
};
const handleResize = (entries: ResizeObserverEntry[]) => {
entries.forEach(entry => {
const index = findIndexForElement(entry.target as HTMLElement);
if (index !== -1) {
const newHeight = entry.borderBoxSize[0].blockSize;
updateItemHeight(index, newHeight);
}
});
};
const findIndexForElement = (element: HTMLElement): number => {
for (const [index, el] of observedElements.entries()) {
if (el === element) return index;
}
return -1;
};
const onScroll = (event: Event) => {
const target = event.target as HTMLElement;
scrollDirection.value = target.scrollTop < scrollTop.value
? 'up'
: 'down';
scrollTop.value = target.scrollTop;
};
onMounted(() => {
resizeObserver.value = new ResizeObserver(handleResize);
});
onUnmounted(() => {
resizeObserver.value?.disconnect();
});
</script>Just use a library
We built this from scratch to understand how it works, but for production? Use a library. Virtual scrolling has tons of edge cases we didn’t cover, like horizontal scrolling, grid layouts, sticky headers, keyboard navigation, screen reader support, touch devices, window resizing, and animations.
Good options:
- vue-virtual-scroller: Vue 3
- virtua: framework-agnostic
- @tanstack/virtual-core: works everywhere
I’d only roll my own if I had a requirement none of them covered, and I’d want to be pretty sure of that first.