Vue Performance: Large Lists, Memoization and Virtualization
AI generated
<v/>
{ }
Vue.js · Performance · Virtualization · Memoization
Vue Performance: Large Lists,
Memoization and Virtualization

A Vue application that runs smoothly with 50 entries can grind to a halt with 5,000 entries. Virtualization, memoization and targeted profiling are the three levers that keep large lists performant, measured, not merely felt.

17 min read v-memo · computed · vue-virtual-scroller · code splitting · profiling Vue 3 · Vite · Chrome DevTools · Vue DevTools

1. Measure before optimizing: the profiling workflow

The most important rule of Vue performance optimization: measure before you optimize. Premature optimization without data leads to more complicated code without solving the actual problem. The first step is always profiling, using the Chrome DevTools Performance tab, the Vue DevTools component tree, and Lighthouse. Only once concrete measurements show which component spends how much time on rendering does an optimization effort have a clear target.

In Chrome DevTools, the Performance tab with the flame chart enabled provides a detailed view of which JavaScript functions cost how much time. Vue renders are recognizable in the flame chart by entries such as patch, updateComponent and renderWithContext. The Vue DevTools Timeline tab shows which components re-render on a user interaction and how long each render takes. A value above 16ms per render means the 60fps frame-rate threshold is missed and the user perceives visible jank.

Lighthouse in audit mode measures Vue performance from the user's perspective using Core Web Vitals: Largest Contentful Paint (LCP), Cumulative Layout Shift (CLS) and Interaction to Next Paint (INP). These metrics are directly tied to Google ranking and provide concrete target values. LCP under 2.5 seconds, CLS under 0.1 and INP under 200ms are the thresholds beyond which Google rates a page as "good". The optimization techniques in this article directly address these metrics.

2. Understanding Vue rendering: what re-renders when?

Vue re-renders a component whenever a reactive dependency changes that was read during the last render. This system is the foundation of Vue performance optimization: if a component reads reactivity it doesn't actually need for rendering, it reacts to changes that don't concern it at all. The classic case: a parent component holds a large reactive object, a child component only reads a single field from it, but is registered as a reactive dependency on the entire object and re-renders on every change to the object, even when the field it actually reads stays unchanged.

Understanding reactive dependency tracking is the key to effective Vue performance optimization. Vue 3 uses proxy-based tracking: every access to a reactive property registers the current component as a subscriber. When the property changes, Vue notifies all subscribers and schedules a re-render. Isolating reactive properties inside computed properties limits the subscriber set: only the computed property observes the broadly dependent reactive data, and only components that read the computed property get notified of changes.

3. computed and watch: memoization in the reactive system

Computed properties are the primary memoization tool in Vue for Vue performance optimizations. A computed property is only recalculated when one of its reactive dependencies changes. Between changes it returns the cached result without re-executing the calculation logic. This is especially relevant for expensive transformations of large datasets: filtering, sorting and transforming a list of thousands of entries isn't performed on every render of the component, only when the source data or the filter parameters change.

The most common Vue performance mistake with computed properties is accidentally using methods instead of computed for values that could be cached. A method called inside a template expression runs its calculation on every render, no caching at all. A computed property runs the calculation once and caches the result. For complex list transformations, formatting and derived data, this difference is substantial. The second mistake: putting side effects inside computed properties. Computed properties should be pure functions, transforming data, not triggering API calls or mutating state. Side effects belong in watch.


// Performance comparison: method vs. computed for expensive list transformation
// Use computed, cached between renders, recalculated only when dependencies change
import { ref, computed } from 'vue'

export function useProductList(rawProducts) {
  const searchQuery = ref('')
  const sortField = ref('name')
  const sortDirection = ref('asc')
  const selectedCategory = ref(null)

  // GOOD: computed, runs once, cached until dependencies change
  const filteredProducts = computed(() => {
    const q = searchQuery.value.toLowerCase()
    const cat = selectedCategory.value

    return rawProducts.value
      .filter(p => {
        if (cat && p.category !== cat) return false
        if (q && !p.name.toLowerCase().includes(q)) return false
        return true
      })
  })

  // Chain computeds, each layer only recalculates when its inputs change
  const sortedProducts = computed(() => {
    const field = sortField.value
    const dir = sortDirection.value === 'asc' ? 1 : -1

    return [...filteredProducts.value].sort((a, b) => {
      if (a[field] < b[field]) return -1 * dir
      if (a[field] > b[field]) return 1 * dir
      return 0
    })
  })

  // BAD PATTERN (do NOT do this, recalculates on every render):
  // methods: { getFilteredProducts() { return rawProducts.filter(...) } }

  return { searchQuery, sortField, sortDirection, selectedCategory, sortedProducts }
}

4. v-memo: selectively freezing list rows

v-memo is a Vue 3 directive specifically for Vue performance in large lists. It freezes the virtual DOM of a component or element as long as the specified dependencies remain unchanged. When Vue renders a list of thousands of elements with v-for and the parent component re-renders, Vue normally checks every element to see whether it needs updating. With v-memo="[item.id, item.selected]", Vue skips vnode creation for all elements where item.id and item.selected are unchanged. This saves substantial rendering time for lists where only a few elements actually change.

The typical use case for v-memo in the context of Vue performance optimization: a list of products where a "selected" state can be toggled. Without v-memo, every selection change would re-render all list elements. With v-memo="[item.id, item.isSelected]", only the rows where isSelected actually changed get re-rendered. For 1,000 entries where 997 stay unchanged, that's a reduction in rendering effort to 0.3% of the original value. Combining v-memo with v-for is almost always worthwhile when the list is large and elements can be selectively selected or flagged.

5. shallowRef and shallowReactive: limiting reactivity

The default ref() and reactive() in Vue make all nested properties of an object reactive, even when only the top level of the object changes and reactivity at deeper levels is not needed. For large datasets such as lists with hundreds of objects, this can lead to substantial overhead when creating proxy watchers. Vue performance optimization via shallowRef and shallowReactive limits reactivity to the top level: only changes to the ref itself or to direct properties of the reactive object trigger re-renders.

A typical use of shallowRef for Vue performance: a list of product objects that arrives as a whole from the API and is replaced as a whole. With const products = shallowRef([])` and `products.value = await fetchProducts(), only replacing the entire array triggers a re-render. Vue doesn't need to watch every nested value of every product object for reactivity. This saves significant memory and setup time when loading large lists. If individual properties of the objects need to be reactive, use triggerRef explicitly after mutations.

6. Virtualization with vue-virtual-scroller

Virtualization is the most powerful lever for Vue performance with long lists: instead of rendering every element into the DOM, a virtual scroller only renders the elements currently within the visible area of the container, plus a small buffer above and below. A list with 10,000 entries renders maybe 30 to 50 elements in the DOM at any given moment. DOM overhead, memory usage and initial rendering stay constant as a result, regardless of the total size of the list.

vue-virtual-scroller is the standard library for virtualization in Vue performance projects. It offers three components: RecycleScroller for lists with a fixed item height, DynamicScroller for lists with variable item height and DynamicScrollerItem as a wrapper for the content inside the dynamic scroller. RecycleScroller is the most performant because it recycles DOM elements, when scrolling it moves DOM nodes instead of deleting and recreating them. This results in smooth scrolling even when scrolling very quickly through long lists.


<!-- ProductList.vue, virtualized list with vue-virtual-scroller -->
<!-- Renders 10,000+ products with constant DOM size (~30 nodes visible) -->
<template>
  <RecycleScroller
    class="scroller h-screen overflow-y-auto"
    :items="sortedProducts"
    :item-size="120"
    key-field="id"
    v-slot="{ item }"
  >
    <!-- v-memo ensures rows only re-render when relevant data changes -->
    <div v-memo="[item.id, item.isSelected, item.price]" class="product-row">
      <ProductCard
        :product="item"
        :selected="item.isSelected"
        @select="toggleSelect(item.id)"
      />
    </div>
  </RecycleScroller>
</template>

<script setup>
// Import only the component needed, tree-shaking removes unused components
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import ProductCard from './ProductCard.vue'
import { useProductList } from '@/composables/useProductList'

const { sortedProducts, toggleSelect } = useProductList()
</script>

7. Code splitting and lazy loading of routes and components

Code splitting is one of the most effective Vue performance optimizations for initial load time. When a Vite application is built without code splitting, the build produces a single bundle containing every component, even though most of them aren't needed at all on the initial page load. Lazy loading with dynamic import, () => import('./HeavyComponent.vue'), splits the application into separate chunks that only load when they're actually needed.

In Vue Router, routes are typically defined as lazy-loaded components by default: component: () => import('@/views/ProductDetail.vue'). That means the JavaScript for the product detail page only loads once the user navigates to that route. For large admin interfaces or feature-rich applications, this can shrink the initial bundle from several megabytes down to a few hundred kilobytes, with a direct impact on LCP and time-to-interactive. Vue performance through code splitting requires no change to component logic, only to the import declarations.

8. Debouncing and throttling in reactive systems

In Vue performance optimizations, debouncing and throttling are frequently overlooked, even though they matter significantly for user-initiated events such as text input, scroll events and resize events. A watch on a search field that fires an API request on every keystroke wastes network resources and can let the display be overtaken by stale responses. A 300ms debounce waits until the user has stopped typing before the request is sent, reducing the number of API requests in a realistic typing scenario by 90%.

For Vue performance with scroll and resize events, throttling is the right technique: the handler is called at most once per defined time window instead of on every event firing. A resize handler that repositions a Vue component doesn't need to run 60 times per second, once every 100ms is entirely sufficient. VueUse (@vueuse/core) offers ready-made composables for debouncing (useDebounceFn), throttling (useThrottleFn) and reactive debounced refs (refDebounced) that integrate seamlessly with Vue's reactive system.

9. Performance techniques compared

The various Vue performance optimization strategies apply to different scenarios and have different effort-to-benefit profiles. Choosing the right technique depends on what profiling has identified as the bottleneck.

Technique Problem Effort Effect
Virtualization 10,000+ DOM nodes Medium Very high, constant DOM
computed instead of method Expensive calculations in the template Minimal High, caching between renders
v-memo Unnecessary re-rendering of rows Minimal High for selective updates
shallowRef Too many watchers for large objects Low Medium, less proxy overhead
Code splitting Large initial bundle Low Very high, improves LCP, TTI

The order of application in practice: first code splitting and lazy loading, because the effort is minimal and the effect on initial load time is maximal. Then secure computed properties for expensive calculations. Then evaluate virtualization for large lists. v-memo and shallowRef are targeted tools for specific Vue performance bottlenecks that profiling has uncovered, not blind upfront optimization.

Mironsoft

Vue performance audit · Frontend optimization · Core Web Vitals

Vue application too slow? We measure and optimize.

We analyze Vue applications with profiling tools, identify concrete performance bottlenecks and implement targeted optimizations with measurable results.

Performance audit

Profiling with Chrome DevTools and Vue DevTools, identifying concrete bottlenecks

Optimization

Virtualization, code splitting, memoization, prioritized by measurable effect

Core Web Vitals

Bringing LCP, CLS and INP to Google's target values, for ranking and user experience

10. Summary

Vue performance optimizations are only effective when they respond to measured bottlenecks. Profiling with Chrome DevTools and Vue DevTools shows which components spend how much time on rendering and which improvements have the greatest effect. Computed properties memoize expensive calculations between renders. v-memo prevents unnecessary re-rendering of list rows that haven't changed. shallowRef limits proxy overhead for large, shallowly mutated datasets. Virtualization with vue-virtual-scroller keeps the DOM constantly small for long lists.

Code splitting is the most effective, lowest-effort entry point into Vue performance optimizations: turning every route and every large component into a lazy import slims down the initial bundle and directly improves LCP and time-to-interactive. Debouncing search input and throttling scroll events prevents unnecessary API calls and JavaScript calculations. Combining these techniques, applied to the actual bottlenecks that profiling has uncovered, results in a Vue application that runs smoothly even under real-world conditions.

Vue Performance, the essentials at a glance

Measure first

Chrome DevTools Performance tab plus Vue DevTools Timeline show which component costs how much time. Profile before every optimization.

computed & v-memo

computed memoizes expensive calculations. v-memo freezes list rows that haven't changed, minimal code, large effect.

Virtualization

vue-virtual-scroller renders only visible elements, DOM size stays constant at 10,000+ entries, scrolling stays smooth.

Code splitting

Dynamic imports for routes and large components, shrink the initial bundle, directly improve LCP and time-to-interactive.

11. FAQ: Vue Performance

1How do I identify which component is causing the problem?
Vue DevTools, Timeline, perform the interaction. Shows components and render duration. Values above 16ms cause visible jank.
2When to use vue-virtual-scroller?
Evaluate from 200 to 500 elements, recommended from 1,000+. Integration effort is low, performance gain is substantial.
3v-memo vs. shouldComponentUpdate?
v-memo works at a finer grain, on individual elements within a component. Skips VDOM diffing for the specified dependencies.
4When does computed provide no benefit?
For trivial calculations, the caching overhead is larger than the savings. computed pays off for array filtering, sorting and complex transformations.
5Using shallowRef correctly?
For large arrays/objects replaced as a whole (API responses). Call triggerRef after mutations when nested reactivity is needed.
6refDebounced vs. debounced watch?
refDebounced for template bindings (search input). Debounced watch for side effects (API calls). Both come from VueUse.
7Does code splitting improve LCP?
Smaller initial bundle, less JS parse time, browser renders the largest visible content sooner. Direct LCP improvement.
8Finding unintended re-renders?
Vue DevTools, gear icon, Highlight component re-renders. Components colored red are re-rendering. Often caused by overly broad reactive dependencies.
9Does virtualization affect SEO?
For SEO-critical lists: SSR with Nuxt. The browser takes over virtualization after hydration. For dynamic apps without SEO relevance, client-side virtualization is sufficient.
10Which Core Web Vital benefits the most?
INP reacts directly to JS performance: virtualization, v-memo and debouncing reduce main-thread blocking. Code splitting improves LCP.