Vue.js Performance Optimization
A well-built Vue app can become slow as it grows if you are not mindful of how components render, how large bundles get, and how often Vue re-runs expensive operations. This topic covers the most effective techniques to keep your Vue app fast.
Understanding What Causes Slowness
Diagram: Common Performance Problem Areas
┌─────────────────────────────────────────────────┐ │ LOAD TIME │ │ └── Bundle too large → page takes too long │ │ to download │ ├─────────────────────────────────────────────────┤ │ RENDER TIME │ │ └── Too many components re-rendering when │ │ only a small part of the data changed │ ├─────────────────────────────────────────────────┤ │ COMPUTATION │ │ └── Expensive calculations running on every │ │ re-render instead of being cached │ ├─────────────────────────────────────────────────┤ │ LARGE LISTS │ │ └── Rendering 1000+ items all at once │ └─────────────────────────────────────────────────┘
v-once — Skip Re-Rendering Static Content
Add v-once to elements that display data which never changes after the first render. Vue renders them once and never checks them again.
<!-- Legal text, terms, static labels — never change -->
<p v-once>App version: {{ appVersion }}</p>
<footer v-once>© {{ currentYear }} My Company. All rights reserved.</footer>
Diagram: v-once Skips Future Diff Checks
Normal element: Every re-render → Vue checks if value changed → updates if needed Even if it never changes: 100 renders = 100 checks With v-once: First render → Vue renders the value All future renders → Vue skips this element completely 100 renders = 1 check
v-memo — Cache a Sub-Tree
v-memo caches the rendered output of an element and its children. Vue only re-renders the subtree when the values in the memo array change.
<div v-for="item in largeList" :key="item.id" v-memo="[item.selected]">
<span>{{ item.name }}</span>
<span>{{ item.category }}</span>
<span>{{ item.price }}</span>
<input type="checkbox" :checked="item.selected">
</div>
Diagram: v-memo on a Large List
largeList has 500 items. User selects item #3. Without v-memo: All 500 rows re-render (Vue checks every row) With v-memo="[item.selected]": Only item #3's row re-renders (selected changed from false to true) Remaining 499 rows: Vue returns the cached result instantly
Computed Properties Over Methods for Derived Values
Always use a computed property (not a method call) when displaying a value derived from reactive data in a template. Computed properties cache their result. Methods run every time the component re-renders.
// SLOW: method runs on every render
methods: {
getExpensiveList() {
return this.items
.filter(i => i.active)
.sort((a, b) => b.score - a.score)
.slice(0, 10);
}
}
// FAST: computed caches the result until items changes
computed: {
topActiveItems() {
return this.items
.filter(i => i.active)
.sort((a, b) => b.score - a.score)
.slice(0, 10);
}
}
Lazy Loading Routes
By default, all route components are bundled into one large JavaScript file. Lazy loading splits each route into its own small chunk downloaded only when the user navigates to that route.
// router/index.js
// WITHOUT lazy loading (all loaded upfront):
import HomeView from "../views/HomeView.vue";
import DashboardView from "../views/DashboardView.vue";
import ReportsView from "../views/ReportsView.vue";
// WITH lazy loading (each loads on demand):
const routes = [
{
path: "/",
component: () => import("../views/HomeView.vue")
},
{
path: "/dashboard",
component: () => import("../views/DashboardView.vue")
},
{
path: "/reports",
component: () => import("../views/ReportsView.vue")
}
];
Diagram: Bundle Splitting with Lazy Loading
Without lazy loading: User downloads: main.js (800kb) at startup (includes ALL routes, even ones never visited) With lazy loading: User downloads: main.js (200kb) at startup User visits /dashboard: dashboard.js (120kb) downloads now User visits /reports: reports.js (90kb) downloads now User only downloads what they actually use.
Async Components — Lazy Load Any Component
Use defineAsyncComponent to load any component lazily — not just routes. Combine it with <Suspense> to show a loading state.
<script setup>
import { defineAsyncComponent } from "vue";
const HeavyChart = defineAsyncComponent(() =>
import("./components/HeavyChart.vue")
);
</script>
<template>
<Suspense>
<template #default>
<HeavyChart :data="chartData" />
</template>
<template #fallback>
<p>Loading chart...</p>
</template>
</Suspense>
</template>
Diagram: Async Component Loading
Page loads → HeavyChart not yet downloaded Suspense shows: "Loading chart..." HeavyChart.vue downloads in background Download complete → HeavyChart renders Suspense shows: the actual chart Total startup time is shorter because HeavyChart is not part of the initial bundle.
KeepAlive — Cache Component State
<KeepAlive> prevents a component from being destroyed when it is hidden. When the user returns to it, the component's state is preserved exactly as they left it.
<!-- Without KeepAlive: component destroys and re-creates on every tab switch --> <component :is="activeTab"></component> <!-- With KeepAlive: component stays alive, state preserved --> <KeepAlive> <component :is="activeTab"></component> </KeepAlive>
Diagram: KeepAlive vs No KeepAlive
User is on Tab A (fills out a form, enters data) User switches to Tab B Without KeepAlive: Tab A component is destroyed → form data lost User returns to Tab A → component re-created → form is blank With KeepAlive: Tab A component is kept in memory (inactive but alive) User returns to Tab A → same component shown → form data intact
Using :key to Force Re-Renders
When you change a component's :key, Vue destroys and re-creates it completely. This is useful when you want to reset a component to its initial state.
<!-- Changing userId forces UserProfile to re-create and reload fresh data --> <UserProfile :key="userId" :user-id="userId" />
Avoid Large Reactive Objects
Vue tracks every property of a reactive object deeply. For large, read-only datasets (like data from an API that never changes in the app), use Object.freeze() to tell Vue not to make it reactive. This saves memory and speeds up rendering.
data() {
return {
// Vue will NOT make this reactive — much faster for large read-only data
countryList: Object.freeze([
{ code: "US", name: "United States" },
{ code: "GB", name: "United Kingdom" },
// ... 200 more countries
])
};
}
Debounce Expensive Watchers and Methods
When a method or watcher runs on every keystroke (like a search), debounce it — add a short delay so it only runs after the user stops typing.
<script setup>
import { ref, watch } from "vue";
const query = ref("");
let timer = null;
watch(query, (newVal) => {
clearTimeout(timer);
timer = setTimeout(() => {
performSearch(newVal); // only runs after 300ms pause in typing
}, 300);
});
</script>
Diagram: Debounce Impact
User types "laptop" quickly (7 keystrokes in 1 second): Without debounce: l → search u → search a → search p → search t → search o → search p → search 7 API calls made With 300ms debounce: User types all 7 letters 300ms passes without new input 1 API call made: "laptop"
Summary
Vue performance optimization works on several fronts. Use v-once for static content and v-memo for large lists with partial updates. Prefer computed properties over methods for any derived value. Split your bundle with lazy-loaded routes and async components. Use <KeepAlive> to preserve component state across tab switches. Freeze large read-only datasets so Vue skips making them reactive. Debounce watchers and search methods to prevent excessive API calls. Each technique targets a specific bottleneck — apply the ones that match your app's actual performance needs.
