Vue Performance Optimization
Vue's reactivity system and virtual DOM are fast by default, but as an application grows the difference between a smooth 60 fps interface and a janky one comes down to how much work Vue is asked to repeat. Large lists that re-render on every keystroke, heavy components loaded eagerly on the first paint, and a bundle that ships every route to every visitor are the three problems you will hit most often in production. This lesson covers the concrete tools Vue 3 gives you to fix each one: v-memo to skip re-rendering unchanged subtrees, defineAsyncComponent and route-level lazy loading to split code, <KeepAlive> to cache expensive component instances, and bundle analysis to find what is actually making your app slow.
- Skip unnecessary re-renders of large lists with v-memo
- Code-split heavy components using defineAsyncComponent and lazy route imports
- Cache stateful views with KeepAlive and its include/max limits
- Measure and shrink your bundle with rollup-plugin-visualizer
- Apply shallowRef and v-once to avoid tracking data that never changes
Skipping Re-renders with v-memo
v-memo caches a rendered subtree and only re-renders it when one of the values in its dependency array changes. It behaves like React.memo but is applied at the template level. It is a scalpel, not a default: reach for it only when profiling shows a genuinely expensive list, because the memoization comparison itself has a cost.
The classic use case is a long list where each row is expensive to render and only a few rows change at a time — for example, a virtualized table where a selected flag flips on one item.
vue<template> <div v-for="item in list" :key="item.id" v-memo="[item.id === selectedId]" > <!-- Re-renders only when this row's selected state changes --> <ExpensiveRow :item="item" :selected="item.id === selectedId" /> </div> </template>
The array [item.id === selectedId] is compared against its previous value for each row. If it is unchanged, Vue reuses the last render of that row and skips it entirely, even when the parent re-renders. With a 1,000-row list, moving the selection updates two rows instead of a thousand.
Warning
v-memo="[]" renders once and never updates — it is equivalent to v-once. Always include every reactive value the subtree depends on in the array, or you will ship stale UI that silently fails to update.For content that is truly static after first render, v-once is simpler and cheaper than v-memo because it stores no dependency array to compare.
vue<template> <!-- Rendered a single time, then treated as static --> <footer v-once> <p>© {{ new Date().getFullYear() }} Acme Inc.</p> </footer> </template>
Code-Splitting with Async Components
Shipping every component in the initial bundle delays the first paint. defineAsyncComponent wraps a dynamic import() so a component is fetched only when it is actually rendered. This is ideal for modals, tabs, charts, and anything below the fold.
tsimport { defineAsyncComponent } from 'vue' const AnalyticsChart = defineAsyncComponent(() => import('./components/AnalyticsChart.vue') )
For a production-grade experience you should also handle the loading and error states rather than showing a blank gap while the chunk downloads.
tsimport { defineAsyncComponent } from 'vue' import LoadingSpinner from './components/LoadingSpinner.vue' import ErrorBox from './components/ErrorBox.vue' const AnalyticsChart = defineAsyncComponent({ loader: () => import('./components/AnalyticsChart.vue'), loadingComponent: LoadingSpinner, errorComponent: ErrorBox, delay: 200, // wait 200ms before showing the spinner (avoids flicker) timeout: 10000, // show errorComponent if the chunk fails to load in 10s })
The most impactful place to split is the router. Passing a dynamic import() as the component makes Vue Router create a separate chunk per route, so a visitor only downloads the code for the page they open.
Pro Tip
import() call to name chunks automatically. To group related routes into one chunk, add a magic comment: import(/* webpackChunkName: "admin" */ './views/Admin.vue') — Vite honors the same syntax for backwards compatibility.Caching Instances with KeepAlive
When you switch between components with a dynamic <component :is> or <router-view>, Vue destroys the old instance and creates a new one. That throws away scroll position, form input, and any fetched data, forcing a re-fetch and re-render on every switch. Wrapping the switch in <KeepAlive> keeps inactive instances in memory instead of destroying them.
vue<template> <KeepAlive> <component :is="activeTab" /> </KeepAlive> </template>
Keeping everything alive forever is a memory leak waiting to happen, so scope it. include/exclude restrict which components are cached by name, and max caps how many instances are retained using an LRU (least-recently-used) policy.
vue<template> <!-- Cache only these two components, keep at most 5 instances --> <KeepAlive :include="['UserProfile', 'OrderHistory']" :max="5"> <component :is="activeTab" /> </KeepAlive> </template>
Cached components are not unmounted, so onMounted does not run again when you return to them. Use the KeepAlive-specific lifecycle hooks to refresh data or restart timers when a component becomes visible again.
vue<script setup> import { onActivated, onDeactivated } from 'vue' onActivated(() => { // Runs each time the cached component is shown again refreshLiveData() }) onDeactivated(() => { // Runs when it is hidden but kept in memory — pause work here pausePolling() }) </script>
Analyzing and Shrinking the Bundle
You cannot optimize what you have not measured. rollup-plugin-visualizer generates an interactive treemap of your production build so you can see which dependencies dominate the bundle — the usual culprits are moment.js, lodash imported wholesale, and duplicated copies of a library.
bashnpm install -D rollup-plugin-visualizer
ts// vite.config.ts import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { visualizer } from 'rollup-plugin-visualizer' export default defineConfig({ plugins: [ vue(), visualizer({ open: true, // open the report in the browser after build gzipSize: true, // show gzipped sizes, closer to what users download brotliSize: true, filename: 'dist/stats.html', }), ], })
Run the build and the report opens automatically:
bashnpm run build
Once you can see the offenders, two Vite settings give you quick wins. Manual chunking splits large, rarely-changing vendor code into its own file so it stays cached across deploys, and raising the warning limit only after you have deliberately reviewed chunk sizes keeps the signal meaningful.
ts// vite.config.ts (build section) export default defineConfig({ build: { rollupOptions: { output: { manualChunks: { vue: ['vue', 'vue-router', 'pinia'], charts: ['echarts'], }, }, }, }, })
Note
import { debounce } from 'lodash-es' instead of import _ from 'lodash', and replace moment.js with date-fns or the native Intl API to drop tens of kilobytes.Avoiding Unnecessary Reactivity
Vue makes every property of a ref or reactive object deeply reactive, which costs memory and CPU for large, immutable data such as a parsed 10,000-row dataset you only read. shallowRef and shallowReactive track only the top-level reference, skipping the deep conversion entirely.
tsimport { shallowRef, triggerRef } from 'vue' // Only reassignment of .value triggers updates, not mutation of nested fields const dataset = shallowRef(await fetchLargeDataset()) function reload(next) { dataset.value = next // triggers a re-render // dataset.value.rows.push() would NOT trigger — call triggerRef if needed: triggerRef(dataset) }
Warning
shallowRef will not react to nested mutations. If your code updates a property deep inside the object and expects the view to change, either reassign the whole .value or call triggerRef explicitly. Silent non-updates are the most common bug when adopting shallow reactivity.Common Pitfalls
Best Practices
- Profile before optimizing — use the Vue DevTools performance tab and the browser's Performance panel to find the real bottleneck instead of guessing.
- Lazy-load at the route level first; it is the single highest-leverage change for initial load time.
- Reserve
v-memofor large lists with expensive rows, and always list every dependency it reads. - Scope
<KeepAlive>withincludeandmaxso caching does not turn into an unbounded memory leak. - Re-run
rollup-plugin-visualizerafter adding any new dependency to catch bundle regressions early.
Next Steps
Next Lesson
SSR with Nuxt