Skip to the solution
HardFrontend12 min read

How do you render very large lists efficiently in Vue (virtual scrolling)?

777 views

The problem

Question presented to candidate: "We need to show an audit log with 50,000 entries in a scrollable panel. Rendering it with a plain v-for freezes the page. How would you render it efficiently in Vue? Explain virtual scrolling and what you would build or use."

What a strong answer should cover:

  • Why plain v-for is slow: every row is a component or element to create, lay out and keep in memory; keys and diffing do not remove browser layout cost.
  • Virtual scrolling (windowing): render only the rows in view plus an overscan buffer, use a spacer with the full height so the scrollbar is right, and position the slice with a transform.
  • The maths for fixed heights: start = floor(scrollTop / rowHeight) - overscan, end = ceil((scrollTop + viewport) / rowHeight) + overscan.
  • Variable heights: estimate, measure rendered rows, and correct offsets (what libraries such as TanStack Virtual handle).
  • Supporting choices: shallowRef for the data, stable keys, passive scroll listeners, and alternatives such as pagination or infinite loading.
  • Trade-offs: browser find-in-page and accessibility only see rendered rows; sticky headers and scroll restoration need care.

Clarifying questions expected:

  • "Are rows fixed height or variable?"
  • "Do users need to search the page with Ctrl+F or select across many rows?"

Code / implementation expected: Yes β€” a minimal fixed-height virtual list or the setup of a library.

  • vue
  • performance
  • virtual-scrolling
  • lists

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

12 min read Β· 11 sections

Read the code

VueA fixed-height virtual list in one SFC: 10,000 rows, about 18 in the DOM
<script setup>
import { ref, shallowRef, computed } from "vue";

// 10,000 rows. shallowRef: rows are replaced, never mutated, so no deep proxies are needed.
const rows = shallowRef(Array.from({ length: 10000 }, (_, i) => ({ id: i, label: `Row #${i}` })));

const ROW_HEIGHT = 32;     // fixed row height in px: makes the maths exact
const VIEWPORT = 320;      // visible height in px (10 rows)
const OVERSCAN = 4;        // extra rows above and below to hide blank flashes while scrolling

const scrollTop = ref(0);
const onScroll = (e) => { scrollTop.value = e.target.scrollTop; };

const start = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_HEIGHT) - OVERSCAN));
const end = computed(() => Math.min(rows.value.length, Math.ceil((scrollTop.value + VIEWPORT) / ROW_HEIGHT) + OVERSCAN));
const visible = computed(() => rows.value.slice(start.value, end.value));
</script>

<template>
  <p>{{ rows.length }} rows, {{ visible.length }} in the DOM (rows {{ start }} to {{ end - 1 }})</p>
  <!-- The viewport scrolls; the spacer gives it the full scroll height of all rows. -->
  <div class="viewport" :style="{ height: VIEWPORT + 'px', overflowY: 'auto', position: 'relative' }" @scroll.passive="onScroll">
    <div :style="{ height: rows.length * ROW_HEIGHT + 'px' }"></div>
    <!-- Only the visible slice is rendered, moved down with a transform. -->
    <ul :style="{ position: 'absolute', top: 0, left: 0, right: 0, margin: 0, padding: 0, listStyle: 'none', transform: `translateY(${start * ROW_HEIGHT}px)` }">
      <li v-for="row in visible" :key="row.id" :style="{ height: ROW_HEIGHT + 'px' }">{{ row.label }}</li>
    </ul>
  </div>
</template>
VueThe same list with @tanstack/vue-virtual 3.13.47 (checked in Chromium)
<script setup>
import { ref, computed } from "vue";
import { useVirtualizer } from "@tanstack/vue-virtual";

const rows = Array.from({ length: 10000 }, (_, i) => ({ id: i, label: `Row #${i}` }));
const scrollEl = ref(null);

const virtualizer = useVirtualizer(computed(() => ({
  count: rows.length,
  getScrollElement: () => scrollEl.value,
  estimateSize: () => 32,
  overscan: 4,
})));
const items = computed(() => virtualizer.value.getVirtualItems());
const total = computed(() => virtualizer.value.getTotalSize());
</script>

<template>
  <div ref="scrollEl" class="viewport" style="height: 320px; overflow-y: auto">
    <div :style="{ height: `${total}px`, position: 'relative' }">
      <div
        v-for="item in items"
        :key="item.key"
        class="row"
        :style="{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${item.size}px`, transform: `translateY(${item.start}px)` }"
      >
        {{ rows[item.index].label }}
      </div>
    </div>
  </div>
</template>

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 80 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextHard

What do markRaw and toRaw do?

Continue