Performance Optimization: v-memo, Virtual Lists and Code Splitting

Advanced
13 min

Performance Optimization: v-memo, Virtual Lists and Code Splitting

Vue is fast by default: the compiler hoists static content and only patches what changed. Performance problems still appear in two places: rendering very large or frequently updated trees, and shipping a bundle that takes seconds to download. After this lesson you will measure before optimizing, cut render work with v-once, v-memo and shallow reactivity, render huge lists with virtual scrolling, and split your bundle so users download only what they use.

Measure First

Guessing wastes time. Use these tools in order:

  • Vue DevTools shows component render counts and timing; set app.config.performance = true in development to expose component marks in the browser's Performance panel.
  • Lighthouse for page-load metrics such as Largest Contentful Paint.
  • npm run build prints every chunk with its size; rollup-plugin-visualizer renders a treemap of what is inside.

Fix the biggest number, verify, then stop.

Reducing Render Work

Every component re-renders when a reactive dependency it read changes. Three techniques limit that work:

vue
<template> <!-- rendered once, never patched again --> <footer v-once>{{ buildInfo }}</footer> <!-- re-rendered only when the dependency array changes --> <div v-for="item in list" :key="item.id" v-memo="[item.id === selectedId]"> <p>{{ item.name }}</p> <ExpensiveChart :data="item.stats" /> </div> </template>

v-memo is the targeted tool for long v-for lists where each row has expensive children: when selectedId changes, only the two rows whose item.id === selectedId result flipped are patched, the rest are skipped entirely. An empty array (v-memo="[]") behaves like v-once.

Two further rules keep updates cheap:

  • Keep props stable. Passing :active-id="selectedId" to every row re-renders all rows on each change. Pass the boolean :active="item.id === selectedId" instead, so only rows whose value changed update.
  • Use shallow reactivity for big data. Deeply proxying 50,000 objects costs time and memory; shallowRef skips it and you replace the array when data changes. Deep watchers on large structures are equally expensive; watch a specific getter.

computed properties cache, so heavy filtering or sorting belongs there rather than in a method called from the template.

Virtual Lists

Rendering 10,000 DOM rows is slow regardless of framework. Virtual scrolling renders only the rows inside the viewport plus a small buffer and recycles them as the user scrolls:

vue
<script setup> import { RecycleScroller } from 'vue-virtual-scroller' import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' defineProps({ rows: Array }) </script> <template> <RecycleScroller class="scroller" :items="rows" :item-size="40" key-field="id" v-slot="{ item }"> <div class="row">{{ item.name }}</div> </RecycleScroller> </template>

vue-virtual-scroller needs a fixed item-size (or DynamicScroller for variable heights) and a container with a fixed height (.scroller { height: 600px }). @tanstack/vue-virtual is a headless alternative when you need full control over markup. Combine virtualization with v-memo for rows containing components.

Code Splitting

The initial bundle should contain only what the first screen needs. Vite splits automatically at every dynamic import():

javascript
// router: each page becomes its own chunk { path: '/reports', component: () => import('@/views/ReportsView.vue') } // component: loaded when first rendered const MarkdownEditor = defineAsyncComponent(() => import('@/components/MarkdownEditor.vue'))

Group shared dependencies so they are cached across deployments:

javascript
// vite.config.js export default defineConfig({ plugins: [vue()], build: { rollupOptions: { output: { manualChunks: { vendor: ['vue', 'vue-router', 'pinia'] } } } } })

The vendor chunk changes only when a framework version changes, so returning users keep it cached while your app code updates. Also prefer tree-shakeable imports (import { debounce } from 'lodash-es' rather than import _ from 'lodash'), check the visualizer for accidental duplicates such as two date libraries, and let your server serve pre-compressed assets.

Common mistakes

  • Optimizing without measuring, and adding v-memo everywhere, which adds bookkeeping cost to cheap components.
  • Using v-if for content that toggles many times per second; v-show avoids repeated mount and unmount.
  • Making third-party class instances (maps, charts, editors) reactive; wrap them in markRaw.
  • Judging chunk loading in development, where Vite serves unbundled modules; measure the production build.
Quick Quiz
Question 1 of 2

What does `v-memo="[item.id === selectedId]"` do on a `v-for` row?

Key Takeaways

  • Measure with Vue DevTools, the browser Performance panel, Lighthouse and the build output before changing code.
  • v-once, v-memo, stable boolean props and shallowRef reduce render work on large trees.
  • Virtual scrolling (vue-virtual-scroller, @tanstack/vue-virtual) renders only visible rows of huge lists.
  • Dynamic import() in routes and defineAsyncComponent split the bundle; manualChunks keeps a cacheable vendor chunk.
  • Prefer tree-shakeable imports and inspect the bundle with a visualizer.

Next lesson: Accessibility in Vue Applications — build components that work with keyboards and screen readers using semantic HTML, ARIA and focus management.

Performance Optimization: v-memo, Virtual Lists and Code Splitting - Vue.js | CodeYourCraft | CodeYourCraft