Vue 3 Large-List Performance: Virtual Scrolling, Rendering, and Memory Costs

Your Vue table is perfectly smooth with 100 rows. It is still acceptable at 1,000. Then someone connects it to a real dataset, and suddenly the page stutters on scroll, input latency appears, and Chrome’s memory graph starts climbing like a mountain trail.

The instinctive answer is usually “add virtual scrolling.” Sometimes that is exactly right. But virtual scrolling only fixes one part of the problem. It reduces the number of DOM nodes and component instances currently mounted. It does not magically make your data model smaller, your row components cheaper, your filtering faster, or your reactive dependencies lighter.

Vue 3 has excellent rendering performance for normal UI work, but large lists expose every architectural weakness: deep reactive state, expensive row components, unstable keys, unmaintained scroll state, and memory retained by observers, images, and third-party objects.

This article looks at large-list performance in Vue 3 as a complete problem: virtual scrolling, rendering cost, reactive overhead, and memory pressure.

TL;DR

  • Virtual scrolling helps by reducing mounted DOM nodes and component instances.
  • It does not reduce the memory cost of the full dataset if you loaded everything into the browser.
  • Fixed-height lists are easy to virtualize; dynamic-height lists are much harder.
  • Use stable keys, cheap row components, shallowRef/markRaw where appropriate, and v-memo for selective updates.
  • Keep selection, expansion, drafts, and focus state outside rows because virtualized rows unmount.
  • For very large remote datasets, server-side pagination or windowed fetching is often better than pure client-side virtualization.

📋 Table of Contents

The real bottleneck: DOM, vnodes, and reactive tracking

When a Vue 3 list becomes slow, the problem is rarely one thing. It is usually a stack of costs that grow together.

A large v-for list creates:

  • VNodes for every item
  • DOM nodes for every rendered element
  • Component instances if each row is a component
  • Reactive dependencies if rows read reactive state
  • Patch work when Vue updates the list
  • Layout and paint work in the browser
  • Memory pressure from all of the above

Vue’s renderer is fast, but it still has to do work proportional to the number of nodes it creates and updates. If you render 20,000 rows, Vue has to create and manage 20,000 row subtrees. The browser then has to lay them out, paint them, and keep them in memory.

Virtual scrolling changes the equation by rendering only the visible window plus a small overscan buffer. Instead of mounting 20,000 rows, you mount perhaps 20 to 60 rows. That can transform scroll performance and initial render time.

But virtual scrolling does not eliminate the dataset itself. If you fetched 200,000 rows and stored them in memory, the browser still holds those objects. If every object is wrapped in a deep reactive proxy, the memory cost can be much higher than the raw JSON size.

So the first mental model is:

Virtual scrolling fixes rendering and DOM cost.
Data architecture fixes memory and reactivity cost.
Row design fixes update cost.

You usually need all three.

1. Profile before virtualizing

Scenario:
A dashboard becomes unusable after a filter returns 30,000 rows. The team immediately adds a virtual scroller, but the page still feels slow when searching or sorting.

Why it matters:
Virtual scrolling is not free. It adds scroll handling, layout calculation, state management, accessibility concerns, and sometimes library weight. If the real issue is an expensive row component or a deep watcher, virtualization may only hide part of the problem.

Before reaching for a virtual list, identify where time is spent.

Useful tools:

  • Chrome DevTools Performance panel
  • Vue DevTools component inspection
  • Memory snapshots
  • Long task markers
  • Rendering stats such as FPS and layout shifts

In the Performance panel, look for:

  • Long script evaluation during initial load
  • Long recalculate style or layout events
  • Repeated scripting during scrolling
  • Component re-render storms after a small state change

If initial render is slow because you mounted 20,000 rows, virtual scrolling is probably the right answer.

If scrolling is fine but typing into a search box freezes the page, the problem may be filtering logic.

If memory grows continuously as the user scrolls, you may have leaks: event listeners, observers, image requests, or detached DOM trees.

If only one row update causes many rows to re-render, your reactive dependencies or row component design may be the issue.

💡 Practical note:
A simple test is to render the list with a deliberately tiny row component: just an id in a plain div. If that is still slow, the list size or DOM count is likely the main problem. If that is fast but your real row is slow, the row component is expensive.

2. Fixed-height virtual scrolling is the easy win

Fixed-height rows are the best-case scenario for virtual scrolling. If every row is exactly 48px tall, the math is simple:

  • Total height = row count × row height
  • Start index = scroll offset ÷ row height
  • End index = visible viewport height ÷ row height
  • Render only that slice

This is the version you should build first if your design allows it.

Here is a compact fixed-height virtual list in Vue 3 using the Composition API:

<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'

const props = defineProps({
  rows: {
    type: Array,
    required: true
  },
  rowHeight: {
    type: Number,
    default: 48
  },
  overscan: {
    type: Number,
    default: 8
  }
})

const scroller = ref(null)
const scrollTop = ref(0)
const viewportHeight = ref(0)
let resizeObserver

const totalHeight = computed(() => props.rows.length * props.rowHeight)

const startIndex = computed(() => {
  return Math.max(
    0,
    Math.floor(scrollTop.value / props.rowHeight) - props.overscan
  )
})

const endIndex = computed(() => {
  return Math.min(
    props.rows.length,
    Math.ceil((scrollTop.value + viewportHeight.value) / props.rowHeight) +
      props.overscan
  )
})

const visibleRows = computed(() => {
  const result = []

  for (let i = startIndex.value; i < endIndex.value; i++) {
    result.push({
      item: props.rows[i],
      index: i,
      top: i * props.rowHeight
    })
  }

  return result
})

function onScroll(event) {
  scrollTop.value = event.target.scrollTop
}

function updateViewport() {
  if (scroller.value) {
    viewportHeight.value = scroller.value.clientHeight
  }
}

onMounted(() => {
  updateViewport()

  resizeObserver = new ResizeObserver(updateViewport)

  if (scroller.value) {
    resizeObserver.observe(scroller.value)
  }
})

onBeforeUnmount(() => {
  resizeObserver?.disconnect()
})
</script>

<template>
  <div
    ref="scroller"
    class="scroller"
    @scroll.passive="onScroll"
  >
    <div
      :style="{
        height: totalHeight + 'px',
        position: 'relative'
      }"
    >
      <div
        v-for="entry in visibleRows"
        :key="entry.item.id"
        :style="{
          position: 'absolute',
          left: 0,
          right: 0,
          height: rowHeight + 'px',
          transform: `translateY(${entry.top}px)`
        }"
      >
        <slot :item="entry.item" :index="entry.index" />
      </div>
    </div>
  </div>
</template>

<style scoped>
.scroller {
  overflow: auto;
  height: 100%;
}
</style>

The important parts are not the exact CSS or variable names. The important ideas are:

  1. The outer container scrolls.
  2. The inner container has the full virtual height.
  3. Only a small number of rows are rendered.
  4. Rows are absolutely positioned using transforms.
  5. Stable keys identify each row.

Using transform: translateY(...) is generally preferable to constantly changing top because transform changes can avoid some layout work and are easier for the browser to composite.

The overscan value is important. If you render exactly the visible rows, fast scrolling can show blank areas before Vue catches up. Rendering a few extra rows above and below the viewport reduces flicker. But too much overscan defeats the purpose of virtualization.

Start with something modest, like 5 to 10 rows, and adjust based on device performance and scroll behavior.

⚠️ Gotcha:
This simple implementation assumes fixed row heights. If your rows contain wrapping text, expandable sections, images with unknown dimensions, or conditional content, this approach will drift out of sync and cause scroll jumps.

3. Dynamic row heights are where custom solutions break

Fixed-height virtualization is easy because every row’s position can be calculated directly from its index.

Dynamic-height virtualization is harder because the position of row 1,000 depends on the measured heights of rows 0 through 999. If those heights are not known in advance, the virtualizer has to estimate, measure, cache, and update.

This is where many hand-rolled virtual scrollers start to fail.

Common problems include:

  • Scroll position jumping when heights are measured
  • Incorrect total height causing scrollbar drift
  • Rows overlapping or leaving gaps
  • Measurement causing forced reflows
  • Cached heights becoming stale after content changes
  • ResizeObserver callbacks triggering layout loops
  • Focus and keyboard navigation landing on unmeasured rows

If your rows have variable height, I would strongly prefer a maintained virtualization library over a custom implementation.

A good library will usually handle:

  • Estimated item sizes
  • Dynamic measurement
  • Height caching
  • Scroll offset correction
  • Overscan management
  • Horizontal virtualization
  • RTL support
  • Window resize handling
  • Scroll restoration patterns
  • Framework integration details

In the Vue ecosystem, options include maintained virtualizers such as TanStack Virtual or Vue-specific virtual scroll components. The right choice depends on your project, but the key point is that dynamic-height virtualization is not just “fixed virtualization with getBoundingClientRect.”

Scenario:
A list has rows that expand to show details. The row height changes when the user clicks a button. The virtual scroller’s cached height is now wrong. If the scroller does not invalidate that cache and remeasure, the total height and scroll offsets become incorrect.

Solution:
When a row changes height, the virtualizer must be notified. Some libraries expose a measurement callback or an element measurement API. Others use ResizeObserver internally. Either way, the height cache must be updated carefully.

🔍 Why this matters:
Dynamic virtualization is not only a rendering problem. It is a scroll geometry problem. The browser scrollbar represents the total content height. If your estimated height and measured height disagree too much, the scrollbar becomes unreliable and the user feels it as jitter.

If you can design your UI so rows have fixed height, do it. If you cannot, use a library and test on low-powered devices.

4. Make each row cheap to render

Virtualization reduces how many rows are mounted, but it does not make each mounted row free. If your visible window contains 40 rows and each row is expensive, scrolling will still feel bad.

Expensive row patterns include:

  • Deep component trees inside every row
  • Heavy computed properties
  • Date formatting with Intl.DateTimeFormat on every render
  • Complex conditional logic
  • Inline object or array literals in props
  • Passing the entire row object to many nested components
  • Reactive watchers inside each row
  • Tooltips, dropdowns, or popovers mounted eagerly
  • Icons or charts rendered unnecessarily

A row should usually be as boring as possible.

Prefer:

  • Flat props
  • Preformatted display values
  • Minimal conditional branching
  • Lazy mounting of expensive subcomponents
  • Event handlers defined outside render loops when possible
  • One row component instead of several nested wrappers unless needed

Vue 3’s v-memo can help when only part of the list changes.

Example:

<script setup>
import { ref } from 'vue'

const selected = ref(new Set())

function toggleSelected(id) {
  const next = new Set(selected.value)

  if (next.has(id)) {
    next.delete(id)
  } else {
    next.add(id)
  }

  selected.value = next
}
</script>

<template>
  <div
    v-for="row in visibleRows"
    :key="row.id"
    v-memo="[row.version, selected.has(row.id)]"
    class="row"
    :aria-selected="selected.has(row.id)"
    @click="toggleSelected(row.id)"
  >
    <span>{{ row.name }}</span>
    <span>{{ row.status }}</span>
  </div>
</template>

v-memo tells Vue to skip diffing that subtree when the memo dependencies have not changed. In this example, a row only re-renders when its version changes or its selected state changes.

This is useful when a list update should not visually update every row.

But v-memo must be used carefully.

Good memo dependencies:

  • row.version
  • row.updatedAt
  • selected.has(row.id)
  • expandedId === row.id

Bad memo dependencies:

  • [row] if row is recreated on every update
  • [Math.random()]
  • [new Date()]
  • Anything that changes every render

Also, v-memo does not make expensive rendering cheap. It only helps avoid rendering when nothing relevant changed. If the row is expensive when it does render, you still need to simplify it.

Another useful tool is CSS containment:

.row {
  contain: content;
}

This can limit layout and paint invalidation to the row itself. It is not a magic fix, and it can create stacking contexts, but it can help in large lists where rows are visually independent.

🧠 The important part:
Virtualization controls how many rows exist. Row design controls how expensive each existing row is. You need both.

5. Keep huge datasets out of deep reactivity

Vue’s reactivity system is powerful, but it is not free. When you put a large nested array into reactive() or ordinary Pinia state, Vue may create proxies for many objects. For a huge dataset, that can increase memory usage and slow down initial processing.

If your list data is mostly read-only or updated immutably, deep reactivity is often unnecessary.

A useful pattern is to store the dataset in a shallowRef:

import { shallowRef } from 'vue'

const rows = shallowRef([])

async function loadRows() {
  const response = await fetch('/api/rows')
  rows.value = await response.json()
}

function updateRow(id, patch) {
  rows.value = rows.value.map(row => {
    if (row.id !== id) {
      return row
    }

    return {
      ...row,
      ...patch,
      version: (row.version ?? 0) + 1
    }
  })
}

With shallowRef, Vue reacts when rows.value is replaced, but it does not deeply proxy every row. This can be a major win for large datasets.

The trade-off is that direct mutation will not trigger updates:

// This will not trigger reactivity with shallowRef
rows.value[0].status = 'done'

You need to replace the array or the affected row immutably.

That may sound restrictive, but for large lists immutable updates are often safer anyway. They give you a clear trigger point for UI updates and make memoization easier.

For non-reactive heavy objects, use markRaw:

import { markRaw } from 'vue'

const chartConfig = markRaw(rawChartConfig)

This is useful for:

  • Chart configuration objects
  • Map or canvas objects
  • Third-party class instances
  • Large binary-like structures
  • Objects that are used for computation but do not need to trigger Vue updates

If you use Pinia or another store, be careful about putting the entire dataset into ordinary reactive state. A store is convenient, but convenience has a cost when the dataset is huge.

Sometimes the better structure is:

  • Keep the raw dataset outside reactive state
  • Keep UI state reactive: selection, filters, sort, visible range
  • Derive visible rows from raw data plus UI state
  • Replace or version specific rows when they change

This separates “data that exists” from “data the UI currently cares about.”

🚨 Production warning:
A memory snapshot can be misleading if you only look at raw JSON size. A 20 MB JSON payload can become much larger once JavaScript objects, hidden classes, proxies, dependency maps, and retained closures are included.

6. Recycled rows need external state

Virtualized rows unmount. That is the whole point. But developers often forget that local component state disappears when a row scrolls out of view.

Problematic local row state includes:

  • Input drafts
  • Expanded/collapsed state
  • Selection state
  • Focus state
  • Temporary validation errors
  • Open dropdown state
  • Edit mode flags

If these live inside the row component, they will reset when the row leaves the rendered window.

The solution is to move state outside the row.

Selection example:

import { ref } from 'vue'

const selectedIds = ref(new Set())

function toggleSelected(id) {
  const next = new Set(selectedIds.value)

  if (next.has(id)) {
    next.delete(id)
  } else {
    next.add(id)
  }

  selectedIds.value = next
}

Then the row receives selection as a prop or reads it from context:

<template>
  <div
    class="row"
    :aria-selected="selectedIds.has(row.id)"
    @click="toggleSelected(row.id)"
  >
    {{ row.name }}
  </div>
</template>

The same applies to expansion:

const expandedIds = ref(new Set())

And drafts:

const drafts = ref(new Map())

If a row contains an input, do not assume the input’s local value will survive scrolling. Store the draft keyed by row ID.

Stable keys are also critical here.

Use:

<div v-for="row in visibleRows" :key="row.id">

Not:

<div v-for="(row, index) in visibleRows" :key="index">

Index-based keys can cause Vue to reuse DOM nodes and component state for the wrong logical row. In normal lists this can cause subtle bugs. In virtualized lists it can become immediately visible because rows are constantly entering and leaving the rendered range.

Focus is another external state problem. If a user tabs into a row and then scrolls, the focused row may unmount. If your list is keyboard-interactive, you may need to manage focus explicitly, for example by keeping an active row ID and scrolling it into view when necessary.

💡 Practical note:
If your row has form state, treat the virtualized list like a window into a larger form. The form state should live above the list, not inside the temporary row component.

7. Memory costs that survive virtual scrolling

Virtual scrolling reduces mounted DOM nodes, but it does not automatically solve memory problems.

Memory can still grow because of:

  • The full dataset
  • Reactive proxies
  • Detached DOM trees
  • Event listeners not removed on unmount
  • ResizeObserver or IntersectionObserver instances
  • Timers and intervals
  • Third-party widget instances
  • Image requests
  • Canvas or WebGL resources
  • Popovers teleported to body
  • Cached computed values retaining large objects
  • Closures capturing large scopes

A common leak pattern is a row component that creates an observer but never disconnects it:

import { onBeforeUnmount, onMounted } from 'vue'

let observer

onMounted(() => {
  observer = new ResizeObserver(() => {
    // update something
  })

  observer.observe(element)
})

onBeforeUnmount(() => {
  observer?.disconnect()
})

In a non-virtualized list, this mistake may be obvious because the page becomes slow immediately. In a virtualized list, it can appear only after the user has scrolled for a while. Rows mount and unmount repeatedly, and each unmount leaks something small. Over time, memory grows.

Images deserve special attention.

If each row contains an image, use explicit dimensions to avoid layout shifts:

<img
  :src="row.thumbnail"
  width="48"
  height="48"
  loading="lazy"
  decoding="async"
  alt=""
/>

But loading="lazy" alone is not a complete strategy if images are expensive. You may also want to:

  • Load thumbnails only when rows are near the viewport
  • Cancel pending image loads when rows unmount
  • Use smaller image variants
  • Avoid full-resolution images in list rows
  • Use placeholders with known dimensions

Third-party components can also retain memory. If a row creates a chart, map, editor, tooltip instance, or popover, make sure those resources are destroyed when the row unmounts.

For expensive interactive elements, consider rendering them only on demand. For example, instead of mounting a full editor in every row, show a static value and mount the editor only when the user enters edit mode.

Event delegation can also help. Instead of attaching complex behavior to every row, attach one listener to the list container and use a data attribute to identify the row:

<div class="list" @click="onListClick">
  <div
    v-for="row in visibleRows"
    :key="row.id"
    :data-row-id="row.id"
  >
    ...
  </div>
</div>
function onListClick(event) {
  const rowElement = event.target.closest('[data-row-id]')
  if (!rowElement) return

  const id = rowElement.dataset.rowId
  handleRowClick(id)
}

This is not always necessary when virtualization keeps the mounted row count small, but it can simplify behavior and reduce per-row overhead.

🔍 Why this matters:
A virtualized list can look fast while still leaking memory slowly. The first few minutes feel great; the next hour feels worse.

8. Filtering and sorting can be the real jank

A list can be virtualized and still freeze when the user types into a search box.

Why? Because filtering and sorting often run over the entire dataset, not just the visible rows.

If you have 100,000 rows and the user types a search term, this kind of computed property may block the main thread:

const filteredRows = computed(() => {
  const query = searchText.value.trim().toLowerCase()

  if (!query) {
    return rows.value
  }

  return rows.value.filter(row => {
    return row.name.toLowerCase().includes(query)
  })
})

That filter may be fine for 1,000 rows. For 100,000 rows, it can cause noticeable input lag, especially if multiple fields are searched.

Options:

  • Debounce the query
  • Use a web worker for filtering
  • Precompute searchable text
  • Use an index structure
  • Filter on the server
  • Limit the client-side dataset

A simple debounce:

import { ref, watch } from 'vue'

const searchText = ref('')
const debouncedSearchText = ref('')

let timer

watch(searchText, value => {
  clearTimeout(timer)

  timer = setTimeout(() => {
    debouncedSearchText.value = value
  }, 150)
})

Then use debouncedSearchText in the computed filter.

For sorting, avoid mutating the original array if it is reactive and large. Create a sorted copy only when needed:

const sortedRows = computed(() => {
  const copy = [...filteredRows.value]

  copy.sort((a, b) => {
    return a.name.localeCompare(b.name)
  })

  return copy
})

For large lists, caching collators matters:

const collator = new Intl.Collator('en', { sensitivity: 'base' })

copy.sort((a, b) => collator.compare(a.name, b.name))

If filtering and sorting are central to the product, consider moving them to the server. A virtualized list can still use server-side queries. The client renders the returned window and asks for more as the user scrolls.

That pattern changes the problem from “render 100,000 rows” to “fetch and render the right rows.” It often produces a better experience because the browser does less work and the network payload is smaller.

The downside is latency and complexity. You need loading states, error states, cache invalidation, and careful scroll restoration. But for huge datasets, it is usually the right architecture.

⚠️ Gotcha:
If you combine server filtering with virtual scrolling, do not assume the total item count is static. The virtualizer’s total size must update when the query result count changes, and scroll position may need to reset.

9. Choosing between pagination, content-visibility, and virtualization

There is no single correct technique for every large list. The right choice depends on data source, row complexity, expected size, and UX requirements.

ApproachBest whenWhat it fixesHidden costWatch out for
Simple paginationRemote data, modest UX requirementsNetwork payload, memory, DOM sizeNot continuous scrollingPage state, deep links
Infinite scroll with paginationFeeds, logs, activity streamsPayload size and initial renderAccumulated DOM if not virtualizedScroll restoration, duplicates
CSS content-visibilityModerate lists, relatively simple rowsPaint and rendering work for offscreen contentDOM still existsBrowser support, focus, scrollbar behavior
Fixed-height virtualizationLarge client-side lists with known row heightDOM and component instance countScroll math and external stateAccessibility, keyboard focus
Dynamic-height virtualizationLarge lists with variable rowsDOM count plus variable geometryMeasurement complexityUse a maintained library
Server-side windowingVery large remote datasetsData transfer, memory, filtering costLatency and cache complexityLoading states, total count

CSS content-visibility

content-visibility: auto can help browsers skip rendering work for offscreen content. Combined with contain-intrinsic-size, it can reduce initial rendering cost without a full JavaScript virtualizer.

.row {
  content-visibility: auto;
  contain-intrinsic-size: 48px;
}

This can be useful for moderate lists where full virtualization would be overkill.

But it is not a replacement for virtualization when the DOM node count is huge. The elements are still in the DOM. Component state, reactive dependencies, and memory may still exist. Also, behavior around accessibility, find-in-page, and focus can require careful testing.

Use content-visibility as a rendering optimization, not as a complete large-list architecture.

Native pagination

Pagination is unfashionable, but it is often the most robust solution. It gives the browser a small DOM, gives the server a bounded query, and gives the user predictable navigation.

If your product can accept pagination, especially for admin tables or search results, it can be far easier to maintain than a complex virtualized experience.

Virtualization

Virtualization is best when:

  • The user expects continuous scrolling
  • The dataset is large enough to make full rendering impossible
  • Row heights can be controlled or measured reliably
  • You are willing to manage external state and accessibility

If the dataset is remote and huge, virtualization should often be paired with windowed fetching. Render only what you have, fetch more as the user approaches the end, and keep the total count if the API provides it.

Decision guide

Here is how I would think about the problem in a real Vue 3 project.

If the list has fewer than a few hundred simple rows

Do not virtualize by default.

Instead:

  • Use stable keys
  • Keep row components simple
  • Avoid deep watchers
  • Use v-memo if selection or partial updates cause re-render storms
  • Profile before adding complexity

If the list has 500 to 5,000 rows

This is the awkward middle zone.

Try:

  • shallowRef for the dataset
  • Immutable row updates
  • v-memo
  • CSS containment
  • Debounced filtering
  • Possibly content-visibility
  • Pagination if the UX allows

If rows are complex, virtualization may be worth it even at the lower end of this range.

If the list has 5,000 to 50,000 client-side rows

Virtualization is usually justified.

If row heights are fixed, you can use a simple virtualizer. If row heights are dynamic, use a maintained library.

Also ensure:

  • Selection and expansion state live outside rows
  • Keys are stable
  • Rows are cheap
  • Filtering is debounced or moved off the main thread
  • Memory is checked after long scrolling sessions

If the list has more than 50,000 rows

Be careful about loading everything into the browser.

Prefer:

  • Server-side pagination
  • Server-side filtering and sorting
  • Windowed API fetching
  • Virtualized rendering for the fetched window
  • Clear loading and error states

A client-side virtual list cannot fix the fact that the browser downloaded and retained a massive dataset.

If rows contain inputs, editors, or complex state

Move state out of the row component.

Use:

  • Map or object keyed by row ID for drafts
  • External selection state
  • External expansion state
  • Explicit focus management
  • Stable IDs

If rows are editable, treat the list as a view over a form or store, not as a collection of independent components.

If accessibility matters

It does.

Check:

  • Keyboard navigation
  • Focus retention
  • Screen reader list semantics
  • Scroll-to-item behavior
  • Search and find behavior
  • Visible focus indicators
  • ARIA roles where appropriate

Virtual scrolling changes the DOM structure, so accessibility cannot be an afterthought.

A practical final rule:

Use virtual scrolling to control the DOM.
Use shallow data and immutable updates to control reactivity.
Use server-side pagination to control data size.
Use external state to survive row recycling.
Use profiling to prove that the problem you fixed was actually the problem.

Post a Comment

Previous Post Next Post