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.
Guessing wastes time. Use these tools in order:
app.config.performance = true in development to expose component marks in the browser's Performance panel.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.
Every component re-renders when a reactive dependency it read changes. Three techniques limit that work:
<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:
: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.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.
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:
<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.
The initial bundle should contain only what the first screen needs. Vite splits automatically at every dynamic import():
// 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:
// 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.
v-memo everywhere, which adds bookkeeping cost to cheap components.v-if for content that toggles many times per second; v-show avoids repeated mount and unmount.markRaw.What does `v-memo="[item.id === selectedId]"` do on a `v-for` row?
v-once, v-memo, stable boolean props and shallowRef reduce render work on large trees.vue-virtual-scroller, @tanstack/vue-virtual) renders only visible rows of huge lists.import() in routes and defineAsyncComponent split the bundle; manualChunks keeps a cacheable vendor chunk.Next lesson: Accessibility in Vue Applications — build components that work with keyboards and screen readers using semantic HTML, ARIA and focus management.