skip to content

Speed & Bundle Tuning

Vue's own levers for update and load cost: v-memo and stable props, shallowRef for big data, tree-shaken builds, lazy components and render-trigger hooks. Interviewers probe measuring before tuning.

part ofVue.jsoverview, primer and where to startread it →
on this pageshow

explore

questions

17

In Vue 3, why is rendering a v-for list of 10,000 rows slow, and how does list virtualization fix it?

level: juniorimportance: must knowfreq 55%

answer

  1. cost is what gets mounted
  2. DOM nodes, vnodes, instances
  3. render only near the viewport
  4. computed slice plus spacer height

basics

~20 s

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

A `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
vue
<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

for a junior

Recall that the cost of a long list is the number of mounted rows, and that virtualization renders only what is near the viewport.

for a middle

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.

for a senior

Show judgment about when to virtualize, how lazy proxying interacts with it, and the costs: find-in-page, variable heights, accessibility.

for a principal

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.
open as a page

In Vue 3, how do you keep a heavy component out of the initial bundle, and what can silently undo the split?

level: juniorimportance: must knowfreq 58%

basics

~10 s

Wrap a dynamic import in defineAsyncComponent, e.g. defineAsyncComponent(() => import('./Chart.vue')), and render it only when needed. A static import of the same file elsewhere, or rendering it on first paint, undoes the gain.

open as a page

In Vue 3, when a parent component re-renders, what decides whether each child component re-renders as well?

level: middleimportance: must knowfreq 55%

basics

~20 s

A Vue child re-renders only if its own tracked state changed or the parent hands it changed props or dynamic slots. Props are compared per key by identity, so primitives stay stable while fresh objects, arrays or functions count as changed.

open as a page

When a Vue 3 app feels slow, what can the official Vue devtools extension show you, and why won't it attach to a default production build?

level: juniorimportance: should knowfreq 45%

basics

~20 s

The Vue devtools extension shows the component tree, each component's props and state, a timeline of events, and per-component render and patch timings in development builds. Production builds compile devtools support out unless VUE_PROD_DEVTOOLS is true.

open as a page

In a Vue 3 template, what does the v-once directive do, and when is it the right tool?

level: juniorimportance: should knowfreq 45%

basics

~20 s

v-once renders an element, component or subtree once with the current data, then treats it as static content that every later re-render skips. Use it for content that reads data but never needs to update.

open as a page

In Vue 3, why is calling watch() directly on a large reactive dataset expensive, and what would you watch instead?

level: middleimportance: should knowfreq 40%

basics

~20 s

watch() on a reactive object is implicitly deep: each run traverses and tracks every nested property, and any nested change re-runs it. Watch a narrow getter, a replaced shallowRef, a version counter, or limit depth with a numeric deep in 3.5.

open as a page

In Vue 3, why can holding a large read-only API response in ref() be costly, and when would you use shallowRef() or markRaw() instead?

level: middleimportance: should knowfreq 50%

basics

~20 s

ref() makes an object deeply reactive, so every nested read goes through a proxy trap that records a dependency. For large immutable data, shallowRef() or markRaw() skip that work, at the price of updating only by replacement.

open as a page

Why doesn't a Vue 3 production bundle include Transition or KeepAlive code when the app never uses them, unlike Vue 2's global API?

level: middleimportance: should knowfreq 40%

basics

~20 s

Vue 3 exposes its APIs and built-ins as named ES module exports, and the template compiler imports only the helpers a template uses. A bundler then drops unreferenced exports such as Transition, whereas Vue 2 hung everything on one global Vue object.

open as a page

In Vue 3, what do the onRenderTracked and onRenderTriggered hooks report, and what are their limits?

level: middleimportance: should knowfreq 35%

basics

~20 s

onRenderTracked fires for each reactive read the component's render records; onRenderTriggered fires when a mutation notifies the render effect. Both receive a debugger event with target, operation type and key. They run only in development and never during SSR.

open as a page

In Vue 3, how does v-memo let a large v-for list skip update work, and what breaks when its dependency array is incomplete?

level: middleimportance: should knowfreq 35%

basics

~10 s

v-memo takes a fixed-length array; if every value equals last render's, Vue reuses the cached subtree, skipping vnode creation and diffing. Leave out a value the subtree displays and that part silently stops updating.

open as a page

You are building a Vue 3 log viewer that streams lines until it holds 50,000 rows and must stay responsive; how do you structure its state and rendering?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Hold immutable lines in a shallowRef, batch incoming lines and trigger once per frame, cap the buffer, render only a virtual window keyed by a sequence number, and derive filters with computed instead of deep watchers.

open as a page

A Vue 3 SPA's marketing landing page downloads the whole app bundle and paints late; how would you cut its initial load?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Serve the landing page as pre-rendered HTML — ideally SSG, shipped separately from the SPA — then shrink what JavaScript remains: lazy routes and async components, local instead of global registration, precompiled templates and feature flags.

open as a page

A Vue 3 results panel re-renders on every keystroke in a search box it never displays; how do you find which reactive change triggers it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Confirm the extra updates with the Vue devtools, then add onRenderTriggered to the panel and log the event's type, key and target on each keystroke. The key names the write; then find which read in the panel's render depends on it.

open as a page

In a Vue 3 list of 1,000 row components, changing the selected id makes every row update; how do you make only the affected rows re-render?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Stop passing selectedId to every row; pass a per-row boolean such as :selected="row.id === selectedId", keep the other props stable and declare the row's events, so only the two rows whose boolean flipped fail Vue's props check.

open as a page

In a Vue 3 app, what does setting the __VUE_OPTIONS_API__ compile-time flag to false remove, and what can break?

level: middleimportance: nice to knowfreq 25%

basics

~20 s

VUE_OPTIONS_API set to false lets the bundler drop Options API support: data, computed, methods, watch, lifecycle options, mixins and extends. Components written with setup or script setup keep working; any dependency written with the Options API breaks.

open as a page

In Vue 3, what does setting app.config.performance to true do, and where do you see its output?

level: middleimportance: nice to knowfreq 25%

basics

~20 s

app.config.performance makes Vue write User Timing marks and measures for each component's init, compile, render, patch and mount. You see them as named timings when recording in the browser's performance panel; it only works in development builds.

open as a page

In Vue 3.4+, why does a computed that returns a new object each run still trigger its dependents, and how do you stabilize it?

level: middleimportance: nice to knowfreq 25%

basics

~20 s

Since Vue 3.4 a computed notifies dependents only when its result changed by identity. A freshly built object is always different, so return the previous value, passed as the getter's first argument, when the relevant fields are equal.

open as a page