In Vue 3, why is rendering a v-for list of 10,000 rows slow, and how does list virtualization fix it?
answer
- cost is what gets mounted
- DOM nodes, vnodes, instances
- render only near the viewport
- computed slice plus spacer height
basics
~20 sEvery rendered row costs DOM nodes, vnodes and often a component instance, so 10,000 rows are slow to mount and patch in any framework. Virtualization renders only rows near the viewport, keeping that cost roughly constant.
solid answer
~40 sA `v-for` over 10,000 items creates 10,000 row vnode trees and their DOM nodes on mount, and if each row is a component, 10,000 instances with their own render effects. The browser must lay out and paint all of them, and every re-render of the owning component re-creates and diffs vnodes for every row. Reactivity tuning cannot remove that, because the cost scales with what is mounted. List virtualization mounts only the rows in or near the viewport: a `computed` slice of the array feeds the `v-for`, a spacer element keeps the scrollbar sized for the full list, and an offset places the slice as the user scrolls. The mounted-row count stays around one screenful however long the data gets.
code
vue · 39 lines<script setup lang="ts">
import { computed, ref } from 'vue'
interface Row { id: number; text: string }
const props = defineProps<{ rows: Row[] }>()
const ROW_HEIGHT = 28
const VIEWPORT_HEIGHT = 560
const OVERSCAN = 5
const scrollTop = ref(0)
const start = computed(() =>
Math.max(0, Math.floor(scrollTop.value / ROW_HEIGHT) - OVERSCAN)
)
const end = computed(() =>
Math.min(
props.rows.length,
Math.ceil((scrollTop.value + VIEWPORT_HEIGHT) / ROW_HEIGHT) + OVERSCAN
)
)
const visible = computed(() => props.rows.slice(start.value, end.value))
function onScroll(e: Event) {
scrollTop.value = (e.target as HTMLElement).scrollTop
}
</script>
<template>
<div :style="{ height: VIEWPORT_HEIGHT + 'px', overflowY: 'auto' }" @scroll="onScroll">
<div :style="{ height: rows.length * ROW_HEIGHT + 'px', position: 'relative' }">
<div :style="{ transform: `translateY(${start * ROW_HEIGHT}px)` }">
<div v-for="row in visible" :key="row.id" :style="{ height: ROW_HEIGHT + 'px' }">
{{ row.text }}
</div>
</div>
</div>
</div>
</template>go deeper
Recall that the cost of a long list is the number of mounted rows, and that virtualization renders only what is near the viewport.
Explain the moving parts in Vue terms: a scrollTop ref, computed start and end, a computed slice fed to v-for, a spacer and an offset.
Show judgment about when to virtualize, how lazy proxying interacts with it, and the costs: find-in-page, variable heights, accessibility.
Frame it as a product trade-off: pagination, virtualization or a server-side search each change the UX differently; pick per surface, not per framework habit.
## Where the cost of a long list comes from A long `v-for` is rarely slow because of Vue's reactivity. It is slow because of **what it mounts**. For each item Vue produces: - a **vnode** subtree describing the row, rebuilt whenever the component that owns the list re-renders; - the **DOM nodes** for that row, which the browser has to style, lay out and paint; - when the row is its own component, a **component instance** with props, a `setup()` run and a render effect of its own. At 10,000 rows that is tens of thousands of DOM nodes. The first mount blocks the main thread, memory grows with every row, and each re-render of the owning component re-creates and diffs 10,000 child vnodes even when a single row changed. The Vue performance guide says it plainly: a list with thousands of items **will** be slow however fast the framework is, because of the sheer number of DOM nodes the browser handles. ## What virtualization changes **List virtualization** (also called windowing or virtual scrolling) renders only the rows inside, or close to, the visible viewport. The dataset can hold 10,000 or a million items; the DOM holds about one screenful plus a small **overscan** buffer on each side, so fast scrolling does not flash blank space. Mount time, memory and patch cost become roughly constant with respect to the dataset size. ## A minimal Vue implementation For rows of one fixed height the technique is a handful of reactive values: 1. Track the scroll container's `scrollTop` in a `ref`, updated from a `@scroll` listener. 2. Derive `start` and `end` indexes with `computed`: `start` is `scrollTop / rowHeight` minus the overscan, `end` is the last visible index plus the overscan, both clamped to the array bounds. 3. Derive the rendered rows with `computed(() => rows.slice(start, end))` and give that array to `v-for`. 4. Give an inner spacer element the height `rows.length * rowHeight`, so the scrollbar represents the whole list. 5. Offset the rendered slice by `start * rowHeight` (a `translateY` or a top padding) so each row appears where it would sit in the full list. When the user scrolls, only `scrollTop` changes. The computed slice updates and Vue patches the rows that entered or left the window. Keying rows by a **stable item id** lets rows that remain in the window be patched in place instead of being remounted. ## How it interacts with Vue's reactivity Virtualization also limits reactivity work, because Vue 3's deep conversion is **lazy**: a nested object inside a `reactive()` or `ref()` value becomes a proxy only when it is first read through its reactive parent. Rendering 30 rows reads 30 items, so only those are proxied and tracked by the render. That saving disappears as soon as something else walks the whole array - a filtering or sorting `computed`, or a deep watcher - which is why big read-only datasets are often held in `shallowRef()` as well. ## Choosing an approach | Approach | Rows mounted | What it costs | |---|---|---| | Render everything | all | slow first mount, high memory, full-list diffs | | Hide off-screen rows with `v-show` | all | still mounted and diffed; only `display` changes | | Server pagination | one page | user moves between pages; a different UX | | Virtualization | about one screenful | scroll math; find-in-page and accessibility work | ## Trade-offs worth naming in an interview - **Find-in-page** in the browser cannot see rows that are not in the DOM. - **Variable row heights** need measuring and a height cache; that is where a community virtual-scroller library usually beats a hand-rolled version. - **Assistive technology** only sees rendered rows unless you expose the total count, for example through ARIA row-count or set-size attributes. - **Small lists** of a few hundred simple rows rarely justify the complexity. Measure first, then virtualize the list that the profiler actually blames. The short version for an interview: Vue can make each row cheap, but it cannot make ten thousand mounted rows free; virtualization changes how many rows exist, which is the only lever that scales.
- Why not keep all rows and hide the off-screen ones with v-show?`v-show` only toggles the CSS `display` property. Every row is still mounted, so the DOM nodes, vnodes and component instances all exist, memory is unchanged, and each re-render still diffs the full list. Hidden elements skip layout and paint, but the mount and update cost that makes the list slow remains.
- Does virtualization also reduce reactivity overhead on a deeply reactive array?Partly. Vue 3 proxies nested objects lazily, on first read through a reactive parent, so rendering a 30-row window only proxies and tracks the items those rows read. Anything that walks the whole array - a filter or sort `computed`, a deep watcher - still touches every item through the proxy, so large read-only data is usually also moved into `shallowRef()`.
saying these in an interview costs you the question
- Vue's virtual DOM makes rendering 10,000 rows cheap enough by itself.
- Hiding off-screen rows with v-show removes their rendering cost.
- Virtualization means fetching the data page by page from the server.
- Moving the rows into shallowRef() fixes a slow 10,000-row list on its own.
- The scrollbar sizes itself correctly without a spacer element.