Virtual rendering for massive lists

by Brian Simon ()

Rendering 50,000 rows creates a slow, memory-heavy DOM. Virtual rendering avoids that cost by mounting only the rows visible in the viewport.

This demo scrolls through 10 million logical rows with dynamic heights. It tracks Scroll Position as a row index and an offset within that row:

10 million logical rows. A bounded pool of reusable rows renders the viewport and overscan.

Rows can expand, collapse, or change height when text wraps. To build this component, start with prefix sums and their memory and browser limits. Then use Scroll Position to place rows without maintaining a pixel map of the dataset.

When virtual rendering helps

A log viewer is a good example. Developers might need to scroll through thousands of entries while tracing a problem, and pagination would split the context they’re following.

Rendering every log entry creates a DOM element for each one:

<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>

That works for a small dataset, but the DOM grows with every rendered entry. As the list grows, rendering and updating all those nodes can slow the page.

If the viewport shows about 20 items, the component only needs to mount those items and a small buffer. This buffer, called overscan, lets the component measure rows before they enter view. The rest of the list can stay unmounted. The remaining problem is deciding which rows to render and where to put them.

Dynamic heights and prefix sums

For rows of a fixed height, row 100 goes at 100 × rowHeight. That calculation doesn’t work when heights vary. Social media posts range from one line to several paragraphs and images. Comment threads mix short replies with code blocks.

One approach is to give every row an offset from the beginning of the list. Each offset is the sum of all the heights before it:

offset[0] = 0
offset[1] = height[0]
offset[2] = height[0] + height[1]
offset[3] = height[0] + height[1] + height[2]

Where prefix-sum tracking reaches its limits

Memory grows with the row count

A full-dataset height array needs one entry per row. A Fenwick tree also needs O(n) storage, even when only a handful of rows are mounted. Reducing DOM nodes doesn’t reduce those arrays.

At ten million rows, initializing and retaining height metadata for the entire dataset becomes a cost of its own. Rows that have never appeared on screen still occupy entries containing estimates. Faster queries don’t change that memory requirement.

Pixel coordinates grow with accumulated height

The global offsets also reach the browser. At an average height of 200px, 100,000 rows need a 20-million-pixel spacer. Ten million rows would need 2 billion pixels.

Browser layout coordinates have finite range and precision. For example, Chromium’s LayoutUnit implementation stores layout values in a fixed-point representation and clamps values outside its range. Large pixel positions can produce placement errors or exceed the supported scrollable dimensions. The practical threshold depends on the browser and row heights. 100,000 rows isn’t a universal cutoff.

Putting visible rows in a shared wrapper can keep their relative offsets small. But if the wrapper still sits at a global offset, and the spacer still represents the whole dataset, the large-coordinate problem remains. A Fenwick tree changes how we calculate the offsets, not how large they become.

Track Scroll Position by row

For this demo, I’d rather avoid maintaining global pixel positions. To render the viewport, the component needs the first visible row, the offset within it, and the heights of nearby rows.

Scroll Position has two parts:

row index + offset within that row

The component measures a local window and positions its rows relative to the viewport. The browser scrolls through a fixed-size area, so adding rows to the dataset doesn’t increase its pixel coordinates. A custom scrollbar tracks progress by row index.

If the viewport and overscan need at most v rows, storing their measurements takes O(v) memory. A fixed viewport, fixed overscan, and a positive minimum row height keep v bounded as the dataset grows. The scroller therefore uses O(1) memory with respect to dataset size, including its pool of up to v reusable row views.

The cost is that a row index doesn’t tell you the exact pixel distance through unseen content. If your application needs that distance, prefix-sum tracking may still be a better fit within the browser’s coordinate limits.

Build the virtual scroller

Keep geometry in RowScroller and use Vue to handle the mounted elements, measurements, and input events. The following methods come from the component used in the demo.

Store a row and an offset

Scroll Position has two fields:

interface ScrollPosition {
	rowIndex: number;
	offsetWithinRow: number;
}

rowIndex identifies the row intersecting the top of the viewport. offsetWithinRow says how many pixels of that row have scrolled past the top. Row indices are zero-based.

For example, this Scroll Position starts 24px into row 9,000,000:

const scrollPosition = { rowIndex: 9_000_000, offsetWithinRow: 24 };

That row starts at -24px in the viewport. If its measured height is 80px, the next row starts at 56px. Neither position depends on the heights of the preceding nine million rows.

Scroll Position identifies the first visible row, so finding it requires no binary search. Positioning that row requires no prefix-sum query.

Measure a local window

The RowScroller class stores heights in a map. The map contains measurements for the rendered window, including overscan. Rows outside that window use an estimate when they return:

interface LocalRow {
	index: number;
	top: number;
	height: number;
}

/** Local geometry only. Unvisited rows have no stored measurements. */
export class RowScroller {
	private readonly count: number;
	private readonly estimatedHeight: number;
	private readonly heights = new Map<number, number>();
	private rowIndex = 0;
	private offsetWithinRow = 0;

	constructor(count: number, estimatedHeight: number) {
		if (!Number.isSafeInteger(count) || count < 0) {
			throw new RangeError('Row count must be a nonnegative safe integer');
		}
		if (!Number.isFinite(estimatedHeight) || estimatedHeight <= 0) {
			throw new RangeError('Estimated height must be finite and positive');
		}
		this.count = count;
		this.estimatedHeight = estimatedHeight;
	}

	get position(): ScrollPosition {
		return { rowIndex: this.rowIndex, offsetWithinRow: this.offsetWithinRow };
	}

	get measurementCount(): number {
		return this.heights.size;
	}

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

The constructor records the row count and an estimated height without allocating an entry for every row.

Starting at the first visible row, walk backward far enough to cover the overscan area. Then walk forward until the viewport and its lower overscan area are covered. This is the class’s layout method:

layout(viewportHeight: number, overscanPixels = 300): LocalRow[] {
	if (!this.count) return [];
	let index = this.rowIndex;
	let top = -this.offsetWithinRow;
	while (index > 0 && top > -overscanPixels) {
		index--;
		top -= this.heightAt(index);
	}
	const rows: LocalRow[] = [];
	while (index < this.count && top < viewportHeight + overscanPixels) {
		const height = this.heightAt(index);
		rows.push({ index, top, height });
		top += height;
		index++;
	}
	return rows;
}

Each LocalRow contains an index, a local top, and a height. Vue positions the row with translateY(row.top). The layout method adds heights only across the rendered window. There is no prefix-sum structure for the dataset.

Vue keeps a snapshot of the local layout. Import ref and shallowRef from Vue and RowScroller from the geometry module. Then create the scroller without allocating the 10 million comments:

const totalItems = 10_000_000;
const containerHeight = 400;
const scroller = new RowScroller(totalItems, 100);
const scrollPosition = ref(scroller.position);
const rows = ref(scroller.layout(containerHeight));
const expanded = ref(new Set<number>());

The component generates each comment’s content from its row index when assigning the row to a slot.

Recycle mounted rows

Keying rows by their dataset index preserves rows that remain in the window. Each arriving row still needs new DOM nodes. For a row component, that also means creating an instance and running its setup code.

Give each mounted view a permanent slot ID instead. A slot holds the current row index, local geometry, and an item object. When a row leaves the window, reuse its slot for an arriving row. Rows that remain in the window keep their slots.

The RowPool class manages those assignments. It calls bindItem when assigning a different row, so layout updates can reuse the item object:

export interface RowSlot<T> extends LocalRow {
  id: number;
  active: boolean;
  item: T;
}

/** Reuses row views and their item objects across rendered windows. */
export class RowPool<T> {
  private readonly slots: RowSlot<T>[] = [];
  private readonly createItem: () => T;
  private readonly bindItem: (item: T, index: number) => void;

  constructor(createItem: () => T, bindItem: (item: T, index: number) => void) {
    this.createItem = createItem;
    this.bindItem = bindItem;
  }

  update(rows: readonly LocalRow[], release?: (slot: RowSlot<T>) => void): RowSlot<T>[] {
    const wanted = new Set(rows.map(row => row.index));
    const retained = new Map<number, RowSlot<T>>();
    const free: RowSlot<T>[] = [];
    for (const slot of this.slots) {
      if (slot.active && wanted.has(slot.index)) {
        retained.set(slot.index, slot);
      } else {
        if (slot.active) release?.(slot);
        slot.active = false;
        free.push(slot);
      }
    }
    const ordered: RowSlot<T>[] = [];
    for (const row of rows) {
      let slot = retained.get(row.index);
      if (!slot) {
        slot = free.pop();
        if (!slot) {
          slot = { id: this.slots.length, index: -1, top: 0, height: 0, active: false, item: this.createItem() };
          this.slots.push(slot);
        }
        this.bindItem(slot.item, row.index);
      }
      slot.index = row.index;
      slot.top = row.top;
      slot.height = row.height;
      slot.active = true;
      ordered.push(slot);
    }
    // Keep active rows in reading order and unused views mounted but hidden.
    return ordered.concat(free);
  }
}

The returned array puts active slots in row order, followed by unused slots. Keeping the DOM in row order preserves the reading and tab order. Unused slots stay mounted but hidden, ready for reuse when the window needs more rows. The pool grows only when a rendered window exceeds its previous capacity.

Create the pool in the component. This example updates an author field in place. The demo also updates content and reuses each slot’s reply array:

const pool = new RowPool(
  () => ({ author: '' }),
  (item, index) => {
    item.author = `User ${index + 1}`;
  },
);
const slots = shallowRef(pool.update(rows.value));

Import RowPool from the pool module before creating the pool. shallowRef lets the pool mutate its slot objects without making each field reactive. Assigning the returned array to slots.value triggers Vue to render the updated fields.

Before reassigning a focused row, move focus to the list container. Otherwise, the same focused button could silently become a control for a different row. The demo’s releaseSlot callback handles that transfer.

Vue can also move a retained row’s DOM node when reordering slots, which can blur a focused control. Capture that control in onBeforeUpdate and restore its focus in onUpdated if the DOM move lost it. Use preventScroll and skip the reveal handler during restoration so the wheel movement still determines Scroll Position.

After computing a window, discard measurements for rows that have left it. Add this method to RowScroller:

retainMeasurements(indices: Iterable<number>): void {
	const retained = new Set(indices);
	for (const index of this.heights.keys()) {
		if (!retained.has(index)) this.heights.delete(index);
	}
}

The component’s refreshRows function recomputes the window and removes state for rows that have left it. It updates the slot assignments and schedules measurements after Vue patches the DOM. Import nextTick from Vue and initialize measurementScheduled and disposed to false:

const refreshRows = () => {
	scroller.clampToEnd(containerHeight);
	rows.value = scroller.layout(containerHeight);
	const retained = new Set(rows.value.map(row => row.index));
	scroller.retainMeasurements(retained);
	for (const index of expanded.value) {
		if (!retained.has(index)) expanded.value.delete(index);
	}
	slots.value = pool.update(rows.value, releaseSlot);
	if (!measurementScheduled && !disposed) {
		measurementScheduled = true;
		void nextTick(() => {
			measurementScheduled = false;
			if (!disposed) measureMounted();
		});
	}
	scrollPosition.value = scroller.position;
};

The clampToEnd method handles measurements at the end of the list, as described in Keep dynamic heights anchored.

Observe each slot element once and associate it with the slot, rather than a permanent row index. Ignore measurements for inactive slots. When a slot changes rows, wait for Vue to patch its content before measuring it.

The demo allocates one comment object per slot and updates it on reassignment. Expanded state belongs to the row index and is discarded when that row leaves the window. Keeping every visited height or expanded row forever would break the constant-memory bound. An application that needs persistent edits must account for that storage separately. A reusable child component must also reset any row-specific local state when its row index changes.

Recycling reduces mounting and allocation work. It doesn’t eliminate allocations: the layout and pool still create temporary collections, and Vue creates virtual DOM nodes. Conditional content can still mount and unmount within a reused row.

Move across row boundaries

Scrolling supplies a pixel delta. Add that delta to the offset within the first visible row. If the result passes the row’s end, subtract its height and advance to the next row. For upward movement, move to the preceding row and add its height until the offset is nonnegative.

For example, start 24px into an 80px row and scroll down by 70px. Scroll Position ends 14px into the next row. This calculation doesn’t need the first row’s global pixel offset.

The class’s moveBy method handles both directions and limits the number of row crossings in one call:

moveBy(delta: number, maxCrossings = 100): number {
	if (!this.count || !Number.isFinite(delta)) return 0;
	if (!Number.isSafeInteger(maxCrossings) || maxCrossings < 1) {
		throw new RangeError('Crossing budget must be a positive safe integer');
	}
	let offset = this.offsetWithinRow + delta;
	let crossings = 0;

	while (offset < 0 && this.rowIndex > 0) {
		if (crossings === maxCrossings) {
			this.offsetWithinRow = 0;
			return offset;
		}
		this.rowIndex--;
		offset += this.heightAt(this.rowIndex);
		crossings++;
	}
	while (offset >= this.heightAt(this.rowIndex) && this.rowIndex < this.count - 1) {
		if (crossings === maxCrossings) {
			this.offsetWithinRow = 0;
			return offset;
		}
		offset -= this.heightAt(this.rowIndex);
		this.rowIndex++;
		crossings++;
	}
	this.offsetWithinRow = Math.max(0, Math.min(offset, this.heightAt(this.rowIndex)));
	return 0;
}

The return value is unconsumed movement. The component carries that remainder into another animation frame, after Vue has rendered the next window. It measures mounted rows before consuming the next chunk. This keeps a large input delta from traversing the whole dataset in one callback.

Unmeasured rows still use estimates. A large movement can therefore cross rows whose exact heights are unknown. The scroller updates local geometry as measurements arrive. The distance through unseen content remains an estimate. For a distant destination, use a row jump instead of walking through estimates.

Keep dynamic heights anchored

ResizeObserver detects changes to mounted rows’ heights. After Vue patches a recycled slot, also measure its current border box. If the new row has the same height as the old one, the observer might not send a notification.

A queued observer entry can predate reassignment. Read the element’s current height and associate it with the slot’s current row index. If the DOM still represents the previous assignment, defer the measurement until after the patch. The geometry module accepts positive, finite measurements and provides a way to invalidate them:

measure(entries: Iterable<readonly [number, number]>): void {
	for (const [index, height] of entries) {
		if (Number.isInteger(index) && index >= 0 && index < this.count &&
				Number.isFinite(height) && height > 0) {
			this.heights.set(index, height);
		}
	}
}

clearMeasurements(): void {
	this.heights.clear();
}

In Vue, apply each changed batch to the map, normalize Scroll Position, and render the window again:

scroller.measure(changed);
pendingMovement += scroller.moveBy(0);
refreshRows();

changed contains the mounted rows whose measured heights differ from the current layout. The component ignores zero heights and subpixel differences smaller than 0.01px to avoid repeated updates with no useful layout change.

A row growing above the viewport changes its own local position, but leaves Scroll Position intact. A row growing below it pushes later rows down. There is no global offset to recalculate and no scrollTop correction for that measurement.

If the first visible row shrinks below offsetWithinRow, moveBy(0) carries the excess into following rows. A width change invalidates the height map because text may wrap differently. The component then measures the mounted rows again.

At the end of the list, the window may not have enough content below the first visible row to fill the viewport. clampToEnd detects that gap and walks backward from the final row to find the last legal Scroll Position:

clampToEnd(viewportHeight: number): void {
	if (!this.count) return;
	let bottom = -this.offsetWithinRow;
	let index = this.rowIndex;
	while (index < this.count && bottom < viewportHeight) {
		bottom += this.heightAt(index++);
	}
	if (bottom >= viewportHeight) return;

	index = this.count - 1;
	let height = this.heightAt(index);
	while (index > 0 && height < viewportHeight) {
		height += this.heightAt(--index);
	}
	this.rowIndex = index;
	this.offsetWithinRow = Math.max(0, height - viewportHeight);
}

Call this method before computing the rendered window. A short list stays at row zero. The End key also keeps the final row aligned with the viewport bottom while local estimates become measurements.

Separate native scrolling from Scroll Position

The scroller has no total dataset height to give the browser. To support native wheel and touch input, the component gives the browser a bounded scroll area:

const container = ref<HTMLElement>();
const runwayHeight = 20_000;
const runwayCenter = (runwayHeight - containerHeight) / 2;
let lastScrollTop = 0;
let pendingMovement = 0;

Inside that fixed-height area, a sticky viewport holds the absolutely positioned rows:

<div ref="container" class="virtual-list-demo"
	:style="{ height: containerHeight + 'px' }" @scroll="onScroll">
	<div :style="{ height: runwayHeight + 'px' }">
		<div class="scroll-viewport" :style="{ height: containerHeight + 'px' }">
			<div v-for="slot in slots" :key="slot.id" v-show="slot.active"
				:ref="el => observeItem(el, slot)" :data-row-index="slot.index"
				:style="{ position: 'absolute', top: 0, width: '100%', transform: `translateY(${slot.top}px)` }">
				<!-- Render this row's content. -->
			</div>
		</div>
	</div>
</div>

Use CSS to make the outer container scrollable and keep the viewport in place:

.virtual-list-demo {
	box-sizing: content-box;
	overflow: auto;
	overflow-anchor: none;
	scrollbar-width: none;
}
.virtual-list-demo::-webkit-scrollbar { display: none; }
.scroll-viewport { position: sticky; top: 0; overflow: clip; }

The sticky viewport stays in place as the native scroll position changes. overflow: clip prevents it from acquiring its own scroll position when a descendant receives focus. A focusin handler updates Scroll Position to reveal that control. Using overflow: hidden here would allow focus to scroll the inner container independently and put it out of sync with Scroll Position.

overflow-anchor: none leaves anchoring to the scroller. The native scrollbar is hidden because its thumb would describe the bounded scroll area, not progress through the dataset.

observeItem associates each element with its slot and registers it with ResizeObserver. The element keeps that association across row assignments. On unmount, the component disconnects its observers.

Call recenter after mounting to start native scrolling at the midpoint. Each scroll event supplies a difference from the previous position. Near either edge, move the native position back to the midpoint:

const recenter = () => {
	if (!container.value) return;
	container.value.scrollTop = runwayCenter;
	lastScrollTop = container.value.scrollTop;
};

const onScroll = () => {
	if (!container.value) return;
	const current = container.value.scrollTop;
	enqueueMovement(current - lastScrollTop);
	lastScrollTop = current;
	if (current < 4000 || current > runwayHeight - containerHeight - 4000) recenter();
};

enqueueMovement adds the delta to pendingMovement and schedules one animation frame. In that frame, the component measures mounted rows, consumes a bounded chunk of movement, and refreshes the window. A nonzero remainder schedules another frame after Vue’s next render.

Update lastScrollTop immediately after recentering. The programmatic scroll event must contribute zero movement, or Scroll Position will jump.

The browser handles a 20,000px scroll area regardless of the row count. Scroll Position determines which rows appear in it.

Jump by row index

A distant jump sets Scroll Position directly:

scroller.jumpTo(9_000_000);

Add jumpTo to the geometry class to clamp the index and reset the offset:

jumpTo(index: number): void {
	if (!Number.isFinite(index)) return;
	this.rowIndex = Math.max(0, Math.min(this.count - 1, Math.round(index)));
	this.offsetWithinRow = 0;
}

Scroll Position starts at the top of the selected row, except where end clamping is needed to fill the viewport. Rendering and measuring the destination window takes work proportional to that window. There is no need to allocate or visit nine million preceding height entries.

The demo exposes a custom vertical scrollbar beside the viewport and a row-number input. Dragging the thumb or clicking the track maps a fraction of its travel to a row index. The thumb has a minimum usable size, since a proportional thumb for ten million rows would be too small to grab.

Before a jump, the component cancels pending movement and recenters the native scroll area. Home and End jump to the list boundaries. When the viewport or scrollbar has keyboard focus, the arrow keys, Page Up, and Page Down move by pixel deltas. Buttons and inputs retain their own keyboard behavior.

The scrollbar thumb represents progress through rows and updates as Scroll Position changes. Halfway means halfway through the row count, even if the first half contains much taller content. An exact scrollbar proportional to total pixel height would require height information for the whole dataset.

Browser features still need attention

Virtual rendering reduces layout work, but browser features can act only on mounted rows. Changing the coordinate model doesn’t remove that tradeoff.

Browser find can’t locate unmounted text. Search needs to query the underlying data and jump to each matching row.

Screen readers also navigate DOM elements. The demo exposes a list and each mounted row’s position in it:

<div role="list" aria-label="Comments">
	<div v-for="slot in slots" :key="slot.id" v-show="slot.active"
		role="listitem" :aria-posinset="slot.index + 1"
		:aria-setsize="totalItems">
		<!-- Render this row's content. -->
	</div>
</div>

Those attributes don’t make the unmounted content accessible by themselves. A production component still needs a way to navigate to that content and preserve focus as rows enter or leave the window. The demo’s focusin handler reveals a mounted control by updating Scroll Position, keeping keyboard focus and visual position in agreement.

Scroll restoration must save scroller.position, not the native scrollTop, which is repeatedly recentered.

To restore Scroll Position, follow these steps:

  1. Restore any persisted row content or expansion state.
  2. Restore the saved row with jumpTo.
  3. Measure that row before applying the saved within-row offset with moveBy. Using an estimated height could advance Scroll Position into the wrong row.
  4. If the content has changed, normalize and clamp the result.

If rows can be inserted or reordered, save a stable item identifier and resolve its current index before calling jumpTo.

Just use a library

Building this from scratch is useful for understanding the algorithm. For production, I’d use a library unless I had a requirement that none of them covered. When choosing a library, check the features your application needs, such as grids, sticky headers, keyboard navigation, screen reader support, and touch input.

These libraries are starting points: