Skip to content
advanced Phase 14 · Performance Optimization and Server-Side Rendering

Virtual Scrolling for Large Lists

Render only visible rows to keep thousands of items responsive.

45m
0 problems
Topic Progress 0%

Rendering 100,000 Rows by Mounting Only the 27 You Can See

An audit console renders 100,000 log rows and the page hangs for three seconds before first paint; every keystroke in the filter field then locks the main thread for another 200 ms. Nothing in the row markup is heavy — it is the sheer count: 100,000 rows at roughly eight DOM nodes each is 800,000 nodes for the browser to lay out, paint, and keep in memory, for a viewport that can only ever display about twenty of them.

Virtual scrolling changes the question from "how do I render 100,000 rows faster" to "why am I rendering rows nobody can see?" The mechanism has three parts: a viewport element with a fixed height and overflow: auto, an inner spacer whose height equals the full dataset so the scrollbar behaves honestly, and a window of rows — the slice inside the viewport plus a small buffer — positioned at their true offsets.

For fixed row heights the math is pure arithmetic:

const OVERSCAN = 5
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN)
const end = Math.min(items.length,
  Math.ceil((scrollTop + viewportHeight) / rowHeight) + OVERSCAN)
const totalHeight = items.length * rowHeight

A minimal Vue implementation is about forty lines:

<script setup>
import { ref, computed } from 'vue'
const props = defineProps({ items: { type: Array, required: true },
                            rowHeight: { type: Number, default: 36 } })
const scrollTop = ref(0), viewportH = ref(600), el = ref(null)
const start = computed(() =>
  Math.max(0, Math.floor(scrollTop.value / props.rowHeight) - 5))
const end = computed(() =>
  Math.min(props.items.length,
    Math.ceil((scrollTop.value + viewportH.value) / props.rowHeight) + 5))
const visible = computed(() =>
  props.items.slice(start.value, end.value)
    .map((item, i) => ({ item, offset: (start.value + i) * props.rowHeight })))
</script>

<template>
  <div ref="el" class="viewport" @scroll="scrollTop = $event.currentTarget.scrollTop">
    <div :style="{ height: items.length * rowHeight + 'px', position: 'relative' }">
      <div v-for="{ item, offset } in visible" :key="item.id"
           :style="{ position: 'absolute', top: offset + 'px', height: rowHeight + 'px' }">
        {{ item.label }}
      </div>
    </div>
  </div>
</template>

The effect on the numbers is not incremental:

Metric Naive render Windowed
Mounted rows 100,000 viewport/36 + 2×5 ≈ 27
DOM nodes ~800,000 ~200
Scroll frame work full layout of 800k nodes patch ~2 entering rows
Filter keystroke diff 100,000 vnodes re-slice 27 items
Spacer height browser lays out everything 100,000 × 36 = 3,600,000 px

Two numbers deserve attention. First, 3.6 million pixels of spacer is legal — Chrome clamps elements somewhere around 33.5 million px, so fixed 40 px rows stay safe up to roughly 800,000 items; past that you must cap the reported height and accept a non-linear scrollbar (real libraries expose this as maxScrollOffset behavior). Second, overscan is a latency buffer, not a rendering strategy: scroll events fire between frames, and the window you compute at event time is what gets painted on the next frame. Five to ten rows of cushion hides fast flings; set it to 100 and you have quietly rebuilt the original problem.

The scroll handler must stay trivially cheap — read scrollTop, nothing else. No deep watchers over the source array, no DOM measurement per event; the slice and offset math above are O(1) arithmetic, which is exactly why fixed heights are the default answer when you get "derive virtual scrolling on a whiteboard" in an interview. Note the server too: during SSR there is no viewport and scrollTop is always 0, so the first server-rendered slice should assume a sensible default height and let onMounted correct it. Server-rendered markup for the whole list is the opposite of what you want — the next topic, Server-Side Rendering and Hydration, covers why that mismatch matters.

Variable Row Heights: Prefix Sums, Estimation, and the Mid-Scroll Jump

Chat messages, audit rows with expanding details, feed items with images — real lists do not have uniform heights. A message is 48 px; an attachment preview is 320 px. The moment heights vary, offset = index × rowHeight stops being true, and every guarantee built on it (total height, which row is visible, where to scroll) has to be re-derived from measurements you do not have yet.

The strategy that works in practice has three moving parts: estimate, measure, anchor.

Estimate first. Render every row at a plausible average — say 64 px — so the spacer is n × 64 and the scrollbar exists immediately. For 10,000 rows that is a 640,000 px track: rough, but usable. As rows enter the viewport you measure their real height with ResizeObserver (not offsetTop reads on every frame — that forces synchronous layout) and store it in a measured[] array.

Compute offsets as prefix sums. Row i's offset is the sum of every height before it. With fixed heights that sum is multiplication; with measured heights you keep a prefix-sum structure, and the two implementation choices are a plain offsets array recomputed lazily or a Fenwick tree (binary indexed tree) when you need O(log n) updates instead of O(n) recompaction. Finding the first row at scrollTop is then a binary search over the offsets array:

function indexAt(offsets: number[], y: number): number {
  let lo = 0, hi = offsets.length - 1
  while (lo < hi) {
    const mid = (lo + hi) >> 1
    if (offsets[mid + 1] <= y) lo = mid + 1
    else hi = mid
  }
  return lo
}
Concern Fixed heights Variable heights
Row offset i × h — O(1) prefix sum — O(log n) lookup
Visible row from scrollTop division binary search over offsets
Spacer total exact by construction estimate, corrected per measurement
Extra state none measured[] + offsets (array or Fenwick tree)
Scrollbar fidelity exact drifts until rows are measured

Anchor so measurement does not jump the page. This is the failure everyone hits second: while the user reads row 500, a lazy image finishes loading in row 490, its height grows from 64 to 240 px, and every row below shifts down 176 px — mid-scroll, visibly. The fix is scroll anchoring: keep the offset of the row at the top of the viewport as your anchor, and when a measurement above it changes, compensate:

const delta = newOffsetBeforeAnchor - oldOffsetBeforeAnchor
if (delta !== 0 && anchorRowOffset <= viewport.scrollTop) {
  viewport.scrollTop += delta   // same content stays put
}

Measurements below the viewport need no correction at all — nothing the user can see depends on them yet, so you can defer those rows' observation until they scroll into range.

Two practical refinements. Estimate per category rather than globally (a message with an attachment starts at 320 px, plain text at 48 px) and the spacer converges to the truth almost immediately, which keeps the scrollbar from visibly growing under the user. And expose scrollToIndex(i) as scrollTop = offset(i) - viewportHeight + height(i) — for chat, "jump to the newest message" is that one line, provided your offsets are current.

The reason interviewers probe this area: it tests whether you can separate what you know (the data) from what you must measure (the layout) and design a data structure — prefix sums plus binary search — that keeps an interactive operation inside a frame budget. A linear scan per scroll event is O(n) work at 60 Hz, and 10,000 rows × 60 scans is how a virtual list becomes slower than the naive one it replaced.

Recycled Rows at 60 fps: Stable Keys, v-memo, and What to Skip

Your windowed list mounts about 27 rows per frame, but a scroll gesture generates sixty updates a second. Whether those updates cost 0.2 ms or 8 ms comes down to two decisions: how rows are keyed, and how much of each row Vue is allowed to skip.

Keys decide what gets reused. As the window slides from rows 10–36 to 11–37, keyed by item.id Vue's diff sees twenty-six keys it already has: it unmounts row 10, mounts row 37, and touches nothing else — two vnode operations per frame instead of twenty-seven. Keyed by array index the key set looks identical across frames, so Vue patches all twenty-seven rows in place, rewriting every binding because the underlying item changed. Worse, the DOM node that held row 10's content is recycled to display row 11: an <input> the user focused scrolls with the window and now edits a different record, an in-flight image request paints onto the wrong cell, and the text the user typed appears one row off. Stable, content-derived keys are what let recycled DOM nodes keep their identity — :key="item.id", never :key="index" and never a random value regenerated per render (random keys make every frame a full unmount/remount).

v-memo decides how much of a surviving row re-renders. Even with correct keys, Vue still re-runs the render function for rows that survive the diff. v-memo caches a sub-tree keyed on an array of dependencies and replays the cached vnode when none of them changed:

<div v-for="item in visible" :key="item.id"
     v-memo="[item.id, expandedId === item.id, selectedId === item.id]">
  <RowHeader :item="item" />
  <RowBody v-if="expandedId === item.id" :item="item" />
</div>

For a scrolled-stable row where nothing in the memo array moved, rendering is skipped entirely — that is the "caches static sub-trees across iterations" property the Vue docs demonstrate with exactly this virtual-list example. The discipline it demands: every dynamic value inside the memoized element must appear in the array. Add a new reactive field to RowHeader and forget to add it to v-memo, and you get stale pixels that only a window resize fixes. Memoize rows whose bindings are mostly static; skip it for rows with rich per-frame state.

The rest of the frame budget is engineering hygiene:

  • Keep the scroll listener cheap and passive — read scrollTop, update a ref, never measure DOM inside it.
  • Position rows with transform: translateY(offset) rather than top: transforms run on the compositor, top re-triggers style recalculation on every frame.
  • Do not put the source array under a deep watcher; a computed slice() over an immutable array is O(window), a deep probe is O(n).
  • Numbers to hold yourself to: windowed scrolling should patch under 1 ms per frame; if profiling shows long tasks, the usual culprits are index keys (full re-patch), heavy child components without v-memo, or layout-thrashing reads in the scroll path.

One thing virtualization silently breaks is accessibility: a screen reader sees only the mounted rows and announces "row 3 of 32" instead of "row 3 of 100,000". Supply aria-setsize="100000" and aria-posinset="item.position" on each row so assistive tech reports the true population.

Finally, know when not to use this. Under ~200 rows, windowing buys nothing and costs you keyboard navigation edge cases, scrollTo behavior, and print layout. And before writing your own, check what you are reimplementing: vue-virtual-scroller's RecycleScroller and @tanstack/vue-virtual already solve fixed and variable heights, scrollToIndex, and the anchoring math above. Once the list is solved, the data layer around it becomes the limiting factor — which is where Nuxt for Full-Stack Vue Apps picks up the story of fetching and serving that 100,000-row dataset sensibly.

Quiz

1. A fixed-height virtual list has 100,000 rows at 36 px, a 720 px viewport, and overscan of 6. How many rows are in the DOM while idle?

Question 1 options

2. Row heights vary and you keep a prefix-sum offsets array for 50,000 rows. How do you find the row at an arbitrary scrollTop on each scroll event?

Question 2 options

3. While a user reads row 500, an image above them finishes loading and a row grows by 176 px. What happens without correction, and what fixes it?

Question 3 options

4. A virtual list keys rows with :key="index". Which symptom actually shows up?

Question 4 options

Flashcards

Question

Given scrollTop, rowHeight, viewportHeight, and overscan, how do you compute the rendered window?

Answer

start = max(0, floor(scrollTop / rowHeight) − overscan), end = min(n, ceil((scrollTop + viewportHeight) / rowHeight) + overscan), total spacer = n × rowHeight. Pure arithmetic — O(1) per scroll event, which is why fixed heights are the default.

Question

Why does a virtual list need a spacer div as tall as the full dataset?

Answer

The scrollbar derives its range from scrollHeight of the content, not from how many children exist. A spacer of n × rowHeight keeps the thumb proportional, the scroll range correct, and end-of-list detection working — while only the window's rows exist as DOM.

Question

How do variable row heights change the windowing algorithm?

Answer

Offsets become prefix sums instead of multiplication: find the visible row by binary search over offsets (O(log n)), track measured heights in a cache (Fenwick tree for O(log n) updates), estimate the total from averages, and anchor scrollTop when a measurement above the viewport changes to prevent jumps.

Question

Why must virtual rows use stable content keys rather than the array index?

Answer

With item ids, the diff mounts/unmounts only the rows entering and leaving the window; with index keys every row's bindings change per frame and recycled DOM carries stale state — focused inputs and in-flight images end up on the wrong record. Keys must be stable and derived from the item.

Question

What does v-memo do for a virtual list, and what is its trap?

Answer

v-memo caches a row's sub-tree keyed on an expression array and replays the cached vnode when none of the deps changed, skipping render for scroll-stable rows. The trap: every dynamic value in the subtree must be listed in the array, or the row renders stale until the deps change.

Revision Notes

Key Takeaways

  • 1. Virtual scrolling renders the viewport slice plus overscan against a full-height spacer: ~32 DOM rows instead of 800,000 nodes, with the scrollbar still truthful
  • 2. Fixed heights make every scroll computation O(1) arithmetic; variable heights replace it with prefix sums, binary search, measurement caches, and scroll anchoring
  • 3. Measurement above the viewport is what causes the mid-scroll jump — compensate scrollTop by the exact delta; measurements below the anchor are harmless
  • 4. Stable content keys let the diff mount/unmount only entering and leaving rows; index keys re-patch every row per frame and recycle inputs/images onto the wrong item
  • 5. v-memo skips render for scroll-stable rows but only if its dep array lists every dynamic value; combine with translateY positioning and a read-only scroll handler

Interview Tips

  • • "Implement virtual scrolling on the whiteboard" — write the three formulas (start, end, spacer), name overscan and why it exists, then state the complexity: O(1) per scroll event for fixed heights
  • • "What breaks with variable row heights?" — offsets need prefix sums, lookup becomes binary search, the spacer is an estimate, and measurements above the viewport require scroll anchoring or the content jumps
  • • "Why not :key="index" in a list?" — answer with the concrete symptom: recycled DOM keeps focus/typed state on the wrong record and every row re-patches each frame; mention that no warning is emitted
  • • "When would you NOT virtualize?" — small lists (<200 rows), print styles, or cases where full-DOM accessibility/SEO matters; knowing the exit criteria signals you have shipped it, not just read about it

Cheat Sheet

Virtual Scrolling for Large Lists

Fixed-height windowing:

start = max(0, floor(scrollTop / h) - overscan)
end   = min(n, ceil((scrollTop + viewportH) / h) + overscan)
total = n * h                 // spacer keeps the scrollbar honest
row.offsetTop = index * h      // absolute / translateY positioning

Variable heights: estimate → measure (ResizeObserver) → anchor.

// visible row from prefix sums
indexAt(offsets, y): binary search, O(log n)
// when a row ABOVE the anchor changes size by delta
viewport.scrollTop += delta     // prevents the mid-scroll jump
Fixed heights Variable heights
offset = i × h, O(1) prefix sums + binary search, O(log n)
spacer exact spacer estimated, corrected per measure
no extra state measured[], offsets (Fenwick for O(log n))

Per-frame rules: stable :key="item.id" (never index/random) · v-memo="[item.id, expanded, selected] covering every dynamic binding · transform: translateY() over top · passive scroll listener that only reads scrollTop · no deep watcher on the source array.

Limits & a11y: Chrome clamps elements near 33.5M px → ~800k rows at 40 px before capping; set aria-setsize / aria-posinset so screen readers announce "row 3 of 100,000"; below ~200 rows, skip virtualization. Libraries: vue-virtual-scroller (RecycleScroller), @tanstack/vue-virtual.

Continue Learning