Vue.js Performance Optimization: A Practical Guide
Vue 3 ships a smaller runtime and better tree-shaking than Vue 2, but a fast framework does not guarantee a fast application. Poorly structured components, unnecessary re-renders, and bloated bundles will tank your Core Web Vitals regardless of the framework version.
Lazy Loading
Split routes and components to reduce initial bundle size.
Render Optimization
Use v-memo, v-once, and computed properties to skip unnecessary work.
Tree-Shaking
Drop unused Vue features and dependencies at build time.
SSR & SSG
Pre-render pages with Nuxt for faster first paint.
Lazy Loading Routes and Components
Every component in your initial bundle delays First Contentful Paint. Vue 3 makes it straightforward to split code at the route level:
// router/index.jsimport { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: () => import('./views/Home.vue') }, { path: '/dashboard', component: () => import('./views/Dashboard.vue') }, { path: '/settings', component: () => import('./views/Settings.vue') } ]})Each import() creates a separate chunk that loads on demand. For non-route components — modals, drawers, heavy widgets — use defineAsyncComponent:
<script setup>import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent(() => import('./components/HeavyChart.vue'))</script>
<template> <HeavyChart v-if="showChart" /></template>The component is only fetched when showChart becomes true. Pair this with a loading state for better UX:
const HeavyChart = defineAsyncComponent({ loader: () => import('./components/HeavyChart.vue'), loadingComponent: ChartSkeleton, delay: 200})Computed Properties vs Methods
This is the single most common performance mistake in Vue applications. Methods re-execute on every render. Computed properties cache their result and only re-evaluate when their reactive dependencies change.
<script setup>import { ref, computed } from 'vue'
const items = ref([/* thousands of items */])const searchQuery = ref('')
// Bad: runs on every render cyclefunction getFilteredItems() { return items.value.filter(item => item.name.toLowerCase().includes(searchQuery.value.toLowerCase()) )}
// Good: only re-runs when items or searchQuery changesconst filteredItems = computed(() => items.value.filter(item => item.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ))</script>If you are calling a function inside your template that performs any filtering, sorting, or transformation — it should almost certainly be a computed.
v-memo and v-once
Vue 3.2 introduced v-memo for skipping re-renders of template subtrees. This is particularly effective inside v-for loops with large lists:
<template> <div v-for="item in items" :key="item.id" v-memo="[item.id, item.updated_at]" > <ItemCard :item="item" /> </div></template>The v-memo directive tells Vue to skip the virtual DOM diff for that element unless one of the listed values changes. For a list of 500 items where only one updates at a time, this eliminates 499 unnecessary diff operations per render.
For content that truly never changes after mount, use v-once:
<template> <header v-once> <h1>{{ appTitle }}</h1> <p>{{ appDescription }}</p> </header></template>Virtual Scrolling for Large Lists
Rendering thousands of DOM nodes kills scroll performance. Virtual scrolling renders only the visible items plus a small buffer. The vue-virtual-scroller package handles this well:
<script setup>import { ref } from 'vue'import { RecycleScroller } from 'vue-virtual-scroller'import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
const items = ref([/* 10,000 items */])</script>
<template> <RecycleScroller :items="items" :item-size="50" key-field="id" v-slot="{ item }" > <div class="row">{{ item.name }}</div> </RecycleScroller></template>This keeps the DOM node count constant regardless of list size. A list of 10,000 items will render roughly 20–30 DOM nodes — the ones visible in the viewport plus a buffer.
For more on JavaScript optimization techniques that complement virtual scrolling, including throttling and debouncing scroll handlers, see our dedicated guide.
KeepAlive for Cached Components
When users navigate between tabs or views, Vue destroys and re-creates component instances by default. For components with expensive setup — API calls, complex DOM — wrap them in <KeepAlive>:
<template> <KeepAlive :include="['Dashboard', 'Analytics']" :max="5"> <component :is="currentTab" /> </KeepAlive></template>The include prop limits caching to specific components. The max prop caps the cache size to prevent memory leaks. Cached components skip their entire setup/mount lifecycle on subsequent visits.
Tree-Shaking Unused Vue Features
Vue 3's modular architecture means unused features get eliminated at build time — but only if you import them correctly:
// Bad: may prevent tree-shaking in some bundler configsimport Vue from 'vue'
// Good: explicit named importsimport { ref, computed, watch, onMounted } from 'vue'In your vite.config.js, enable production-specific feature flags to drop development-only code:
// vite.config.jsexport default defineConfig({ define: { __VUE_OPTIONS_API__: false, // Drop Options API support if using Composition API only __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false }})Setting __VUE_OPTIONS_API__ to false removes the Options API compatibility layer entirely — this can shave several kilobytes from your bundle if your entire codebase uses the Composition API.
Image Optimization
Images are typically the largest assets on any page. Lazy load offscreen images and serve modern formats:
<template> <picture> <source srcset="/img/hero.avif" type="image/avif" /> <source srcset="/img/hero.webp" type="image/webp" /> <img src="/img/hero.jpg" alt="Dashboard overview" loading="eager" fetchpriority="high" decoding="async" width="800" height="450" /> </picture></template>Always set explicit width and height to prevent layout shifts (CLS). For a deeper dive, see our image optimization guide.
SSR and SSG with Nuxt
Client-side rendered Vue apps send an empty HTML shell to the browser. The user sees nothing until JavaScript downloads, parses, and executes. Server-side rendering (SSR) and static site generation (SSG) solve this by sending pre-rendered HTML.
Nuxt 3 is the standard approach for Vue SSR/SSG. For content-heavy pages, use hybrid rendering to statically generate what you can and server-render what you must:
// nuxt.config.tsexport default defineNuxtConfig({ routeRules: { '/blog/**': { prerender: true }, // Static at build time '/dashboard/**': { ssr: true }, // Server-rendered per request '/api/**': { cors: true } }})This gives you static performance for content pages and dynamic rendering where you need it. See our Nuxt.js performance guide for Nuxt-specific optimizations including payload reduction and component islands.
Build and Deployment Optimization
Optimizing your Vue application does not end at the code level. Your build pipeline and deployment process directly affect what users actually receive:
- •Enable compression: Serve Brotli or gzip-compressed assets. Most CDNs handle this, but verify your deployment pipeline compresses static assets.
- •Set cache headers: Hashed filenames (Vite does this by default) allow aggressive caching with
Cache-Control: max-age=31536000, immutablefor assets. - •Analyze your bundle: Run
npx vite-bundle-visualizerto find unexpected large dependencies. A single unoptimized import can add hundreds of kilobytes. - •Automate your deploys: Manual deployment processes introduce inconsistency. Use build pipelines to run your build step, compress assets, and deploy atomically — ensuring every release ships with the same optimizations.
Performance Checklist
- Routes are lazy-loaded with dynamic
import() - Heavy components use
defineAsyncComponent - Filtering/sorting logic uses
computed, not methods - Large lists (100+ items) use virtual scrolling
v-memoapplied tov-forloops with expensive childrenKeepAlivewraps frequently revisited components__VUE_OPTIONS_API__set tofalseif not using Options API- Images use
loading="lazy", explicit dimensions, and modern formats - Bundle analyzed — no unexpected large dependencies
- Core Web Vitals passing (LCP < 2.5s, CLS < 0.1, INP < 200ms)
Measure Your Vue App's Performance
Start by measuring where your Vue application actually stands. Get specific bottleneck identification and optimization recommendations.
More guides: Nuxt.js Performance · Image Optimization · JavaScript Optimization · Core Web Vitals
This content is provided by PageSpeed Analyzer by DeployHQ.