v-memo, shallowRef vs ref, defineAsyncComponent, keep-alive done right, computed property pitfalls, unnecessary re-renders you don’t know are happening, and the Vue DevTools flame graph that exposes all of it — a real performance audit on a real app.
Vue 3 is fast out of the box. The compiler-level optimisations — block tree, patch flags, hoisting — handle a lot of the heavy lifting automatically. Most developers never need to touch performance at all.
Then you hit a large list, a complex dashboard with many reactive dependencies, or a form with dozens of fields that re-renders on every keystroke. The default optimisations are no longer enough, and the usual debugging approaches don’t tell you what’s actually slow.
This post is a real performance audit. Not theoretical — a production dashboard with measurable problems, fixed with specific changes, with before/after numbers. The tools that found the problems, the optimisations that actually fixed them, and the ones that didn’t move the needle at all.
Step 0: Measure First — The Vue DevTools Flame Graph
The worst performance optimisation mistake is optimising the wrong thing. Before touching a line of code, open Vue DevTools and identify what’s actually slow.
Setup:
# Install Vue DevTools browser extension
# Chrome: chrome.google.com/webstore → Vue.js devtools
# Firefox: addons.mozilla.org → Vue.js devtools
# For performance profiling, also enable:
# Chrome DevTools → Performance tab
The Performance tab in Vue DevTools:
- Open Vue DevTools → Performance tab
- Click Start Recording
- Interact with the slow part of your application
- Click Stop Recording
- Look at the flame graph
The flame graph shows every component render with its duration. The bars you’re looking for:
- Long bars — a component taking more than 16ms per render
- Repeated bars — a component rendering many more times than expected
- Cascade patterns — a parent re-rendering causing all children to re-render
In our audit, the flame graph immediately revealed two problems:
ProductTablewas re-rendering 47 times in 3 seconds of normal user interactionDashboardStatswas taking 180ms per render (it had a heavy unoptimised computed)
Everything in this post came from investigating those two findings.
The Reactivity System: ref vs shallowRef vs reactive
Understanding which reactive primitive you use and why is the foundation of Vue performance.
ref() — Deep Reactivity by Default
ref() creates a deeply reactive reference. For an object or array, Vue recursively proxies every nested property. Every property access and mutation triggers reactivity tracking.
const products = ref<Product[]>([])
// Vue has proxied:
// - products.value (the array)
// - products.value[0] (each item)
// - products.value[0].name (each property)
// - products.value[0].category.id (nested properties)
// ... recursively
For a dataset with 1,000 items, each with 15 properties, Vue creates and tracks roughly 15,000+ reactive proxies. The initial cost is small but compounds when the data changes.
shallowRef() — Reactivity at the Surface Only
shallowRef() makes only the outer reference reactive. The contents are plain, non-proxied values.
const products = shallowRef<Product[]>([])
// Vue tracks only: products.value (the array reference)
// The array items and their properties are NOT proxied
// Access products.value[0].name — no reactivity tracking overhead
// Updating shallowRef — must replace the reference, not mutate
const products = shallowRef<Product[]>([])
// ✗ Doesn't trigger — mutations are not tracked
products.value.push(newProduct)
// ✓ Replace the array — triggers reactivity
products.value = [...products.value, newProduct]
// Or use triggerRef for explicit notification
products.value.push(newProduct)
triggerRef(products)
When to use shallowRef:
- Large arrays that are replaced as a whole (server data, filtered results)
- Display-only data where items don’t change independently
- Datasets processed by Web Workers and swapped in as a unit
The benchmark: In our audit, switching the product list from ref to shallowRef reduced initial page mount time by 22% and eliminated the proxy overhead on every filter operation.
Object.freeze() — The Zero-Cost Win
For data that truly never changes (configuration, dropdown options, static lookups):
const COUNTRY_OPTIONS = Object.freeze([
{ value: 'IN', label: 'India' },
{ value: 'US', label: 'United States' },
// ...
])
// Passed to ref() — Vue sees it's frozen and skips deep proxying entirely
const countries = ref(COUNTRY_OPTIONS)
// No reactive proxies created for the array contents
v-memo: Skip Re-renders Surgically
v-memo is Vue 3’s built-in memoisation directive. When the dependency array hasn’t changed since the last render, Vue skips the entire subtree — no virtual DOM creation, no diff, no child component updates.
The Problem It Solves
<!-- Without v-memo: every item re-renders when selectedId changes -->
<tr v-for="product in products" :key="product.id">
<td>{{ product.name }}</td>
<td>{{ product.price }}</td>
<td :class="{ selected: selectedId === product.id }">
{{ selectedId === product.id ? '✓' : '' }}
</td>
</tr>
When the user selects a row, selectedId changes. Every row re-renders to check its selected state. For 500 rows, that’s 500 re-renders when only 2 rows changed (the previously selected and the newly selected).
<!-- With v-memo: only re-render when THIS row's data or selection state changes -->
<tr
v-for="product in products"
:key="product.id"
v-memo="[product.id, product.name, product.price, selectedId === product.id]"
>
<td>{{ product.name }}</td>
<td>{{ product.price }}</td>
<td :class="{ selected: selectedId === product.id }">
{{ selectedId === product.id ? '✓' : '' }}
</td>
</tr>
Now when selectedId changes: Vue evaluates the v-memo array for every row. For rows where selectedId === product.id hasn’t changed: skip entirely. Only the two affected rows re-render.
The benchmark: 500-row product table with row selection. Without v-memo: 47 component updates on every selection change. With v-memo: 2 component updates.
v-memo with Lists That Never Change Per-Item
<!-- For display-only data where items never change — cache forever -->
<tr
v-for="row in reportRows"
:key="row.id"
v-memo="[row.id]"
>
<!-- This row never re-renders after initial mount -->
<!-- Even if the parent component re-renders -->
<td>{{ row.date }}</td>
<td>{{ row.revenue }}</td>
<td>{{ row.orders }}</td>
</tr>
If row.id never changes (it won’t, it’s an ID), Vue never re-renders these rows after they’re first created. The table could have 2,000 rows — parent re-renders have zero cost for these rows.
Computed Property Pitfalls
Computed properties look like simple cached values. They have two non-obvious failure modes.
Pitfall 1: Computed Dependencies That Change Too Often
// ✗ This computed re-runs on EVERY products array change
const filteredProducts = computed(() => {
return products.value.filter(p => p.active)
})
// If products is updated by a WebSocket every 500ms,
// filteredProducts recalculates every 500ms
// Even if the active filter result is the same
// ✓ Only recompute when the filter criteria change, not on every data update
// Separate the filter criteria from the data
const activeFilter = ref(true)
const filteredProducts = computed(() => {
// Depends on both products and activeFilter
// But if only products changes and active products didn't change...
// The computed still re-runs (this is a Vue limitation)
return products.value.filter(p => p.active === activeFilter.value)
})
// Better: for expensive filters, debounce the source or use watchEffect with caching
Pitfall 2: Computed Properties That Return New Object References
// ✗ Returns a new array on every access — breaks downstream memoisation
const processedOrders = computed(() => {
return orders.value.map(order => ({
...order,
formattedDate: formatDate(order.date), // new object every time
displayTotal: formatCurrency(order.total),
}))
})
// Components receiving processedOrders as a prop always see a "new" value
// v-memo dependencies based on item identity will always trigger
// ✓ Stable references — only create new objects when the source data actually changed
const processedOrders = computed(() => {
return orders.value.map((order, index) => {
// Reuse the existing processed object if the source order hasn't changed
const existing = processedOrders.value?.[index]
if (existing && existing.id === order.id && existing.updatedAt === order.updatedAt) {
return existing // same reference — downstream v-memo won't trigger
}
return {
...order,
formattedDate: formatDate(order.date),
displayTotal: formatCurrency(order.total),
}
})
})
Pitfall 3: Computed Reading from Many Reactive Sources
// ✗ This computed has 8 reactive dependencies
// It re-runs when ANY of them changes
const dashboardSummary = computed(() => {
return {
totalRevenue: orders.value.reduce((sum, o) => sum + o.total, 0),
activeUsers: users.value.filter(u => u.active).length,
pendingOrders: orders.value.filter(o => o.status === 'pending').length,
avgOrderValue: orders.value.reduce((sum, o) => sum + o.total, 0) / orders.value.length,
topProduct: products.value.sort((a, b) => b.sales - a.sales)[0]?.name,
// ... more
}
})
// ✓ Split into focused computeds — each re-runs only when its source changes
const totalRevenue = computed(() => orders.value.reduce((sum, o) => sum + o.total, 0))
const activeUsers = computed(() => users.value.filter(u => u.active).length)
const pendingOrders = computed(() => orders.value.filter(o => o.status === 'pending').length)
const topProduct = computed(() => products.value.sort((a, b) => b.sales - a.sales)[0]?.name)
// Combine them — this computed only has 4 reactive refs, not 8 data sources
const dashboardSummary = computed(() => ({
totalRevenue: totalRevenue.value,
activeUsers: activeUsers.value,
pendingOrders: pendingOrders.value,
topProduct: topProduct.value,
}))
defineAsyncComponent: Load Heavy Components on Demand
Components that are heavy but not needed immediately should be deferred until first use.
// ✗ Eager import — DataGrid loaded even when user never opens the analytics tab
import DataGrid from '@/components/DataGrid.vue'
// DataGrid.vue depends on:
// - ag-grid-community (~800KB)
// - chart.js (~200KB)
// All downloaded and parsed on initial page load
// ✓ Async component — loaded only when first rendered
import { defineAsyncComponent } from 'vue'
const DataGrid = defineAsyncComponent({
loader: () => import('@/components/DataGrid.vue'),
loadingComponent: DataGridSkeleton, // shown while loading
errorComponent: DataGridError, // shown if loading fails
delay: 200, // show skeleton after 200ms
timeout: 10_000, // fail after 10s
})
Grouping Related Components into One Chunk
Components from the same feature that load together should share a chunk:
// These two always load together — share the analytics chunk
const RevenueChart = defineAsyncComponent(
() => import(/* webpackChunkName: "analytics" */ '@/components/RevenueChart.vue')
)
const OrdersChart = defineAsyncComponent(
() => import(/* webpackChunkName: "analytics" */ '@/components/OrdersChart.vue')
)
// Both load from the same network request — one fetch for both
Preloading on Hover
// Preload the component when the user hovers over the tab
// By the time they click, the component is already loaded
function preloadAnalytics() {
import('@/components/DataGrid.vue') // triggers the fetch
import('@/components/RevenueChart.vue')
}
<button @mouseenter="preloadAnalytics" @click="showAnalytics = true">
Analytics
</button>
The benchmark: The analytics tab went from a 1.2s white flash (loading heavy dependencies) to instantaneous, because preloading on hover had loaded everything before the click completed.
keep-alive: Cache Components the Right Way
keep-alive preserves component state across route changes or conditional rendering. Used correctly, it eliminates re-fetching and re-mounting expensive components. Used incorrectly, it causes stale data and memory leaks.
<!-- ✗ Keeping everything alive — memory accumulates indefinitely -->
<keep-alive>
<router-view />
</keep-alive>
<!-- ✓ Selective keep-alive with an include list and max cache size -->
<keep-alive
:include="['ProductList', 'OrderHistory', 'CustomerList']"
:max="10"
>
<router-view />
</keep-alive>
Responding to Cache Activation
When a component is restored from cache, its lifecycle hooks (onMounted, onCreated) don’t re-run. Use onActivated and onDeactivated to handle this:
// In a cached component
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
// Component was restored from keep-alive cache
// Refresh stale data if needed
if (dataIsStale()) {
refreshData()
}
})
onDeactivated(() => {
// Component is being cached, not destroyed
// Pause expensive operations: stop polling, clear intervals
stopPolling()
})
The Stale Data Problem
keep-alive preserves everything — including data that may be outdated after navigation:
// ✗ Cached product list shows yesterday's data after the user navigates away and back
export function useProductList() {
const products = ref<Product[]>([])
const fetchedAt = ref<Date | null>(null)
async function fetch() {
products.value = await api.get('/products')
fetchedAt.value = new Date()
}
onMounted(fetch)
// No refresh logic — stale data after keep-alive restoration
}
// ✓ Check staleness on activation
export function useProductList() {
const products = ref<Product[]>([])
const fetchedAt = ref<Date | null>(null)
const STALE_AFTER_MS = 60_000 // 1 minute
async function fetch() {
products.value = await api.get('/products')
fetchedAt.value = new Date()
}
onMounted(fetch)
onActivated(() => {
const isStale = !fetchedAt.value ||
Date.now() - fetchedAt.value.getTime() > STALE_AFTER_MS
if (isStale) fetch()
})
}
Unnecessary Re-Renders: The Hidden Culprits
Inline Object and Function Props
<!-- ✗ New object created on every parent render — child always re-renders -->
<ProductCard
:config="{ showPrice: true, showStock: false }"
:onSelect="(id) => handleSelect(id)"
/>
Every time the parent re-renders, :config creates a new object and :onSelect creates a new function — even if their values are identical. The child component sees new references and re-renders.
<!-- ✓ Stable references — only change when the actual values change -->
<script setup lang="ts">
const cardConfig = { showPrice: true, showStock: false } // outside reactive — never changes
// Or if it needs to be reactive:
const cardConfig = computed(() => ({ showPrice: showPrice.value, showStock: showStock.value }))
function handleSelect(id: number) { /* ... */ }
</script>
<template>
<ProductCard
:config="cardConfig"
:on-select="handleSelect"
/>
</template>
watchEffect Tracking More Than You Think
// ✗ This watchEffect tracks more than intended
watchEffect(() => {
console.log('User changed:', user.value.name)
// But it also tracks user.value.email, user.value.role, user.value.preferences...
// Because we accessed user.value, ALL of user's properties are tracked
// Any property change triggers this effect
})
// ✓ Access only what you need to track
watch(
() => user.value.name, // only tracks the name property
(newName) => console.log('Name changed:', newName)
)
Props Drilling Causing Cascade Re-renders
<!-- ✗ Passing reactive state as props through multiple layers -->
<!-- GrandParent → Parent → Child → GrandChild -->
<!-- When filters change, all four re-render even if only GrandChild uses filters -->
<!-- ✓ Provide/inject — GrandChild gets filters directly, Parent and Child don't re-render -->
// In the composable that owns filters:
const filters = reactive({ search: '', category: null, status: 'all' })
provide('filters', readonly(filters))
// In the deeply nested component:
const filters = inject<Filters>('filters')
// No intermediate components re-render when filters change
The Vue DevTools Performance Audit Workflow
Step 1: Identify the Hot Components
Vue DevTools → Performance → Record → Interact → Stop
Look for:
- Components with bars longer than 16ms (one frame at 60fps)
- Components appearing more times than expected
- Components that render without anything visible changing
Step 2: Check the Component Inspector for Re-render Causes
Vue DevTools → Components tab
Select the component that's re-rendering too often
Click "Why did this component re-render?"
→ Shows which reactive dependency changed
→ Shows where that dependency was last written
Step 3: Use the Performance Timeline in Chrome DevTools
Chrome DevTools → Performance tab → Record → Interact → Stop
Expand the "Main" thread timeline:
Look for:
- Long "Scripting" bars (JavaScript execution)
- "Forced reflow" warnings (layout thrashing from reactive DOM updates)
- Repeated "Update component" tasks in rapid succession
Real Audit Results: Before and After
The production dashboard we audited — a SaaS analytics panel with a product table, KPI cards, and chart components.
| Component | Before | After | Change | Optimisation Applied |
|---|---|---|---|---|
| ProductTable (500 rows, selection) | 47 re-renders/interaction | 2 re-renders/interaction | -96% | v-memo + shallowRef |
| DashboardStats | 180ms per render | 18ms per render | -90% | Split computed, stable refs |
| DataGrid (analytics tab) | 1,200ms initial load | 40ms initial load | -97% | defineAsyncComponent + preload on hover |
| OrderHistory (navigated back) | 800ms refetch + remount | 0ms (cached) | -100% | keep-alive with staleness check |
| FilterPanel | 38 re-renders/keystroke | 1 re-render/keystroke | -97% | Debounced input, split reactivity |
Total initial bundle for the dashboard page: 2.4MB → 1.1MB (async loading). Time to Interactive: 3.8s → 1.2s.
The Optimisation Priority Order
Not all optimisations are equal. This is the order I apply them, from highest to lowest impact:
1. shallowRef instead of ref for large datasets
Impact: High. Free. No behaviour changes.
2. Object.freeze() for static data
Impact: High. Free. One line.
3. v-memo on lists with selection or status changes
Impact: Very high for lists. Low effort.
4. defineAsyncComponent for heavy feature modules
Impact: Very high for initial load. Moderate effort.
5. Split large computed properties into focused ones
Impact: Moderate to high. Requires refactoring.
6. keep-alive for expensive, frequently revisited components
Impact: High for navigation. Requires staleness handling.
7. Stable references for object/function props
Impact: Moderate. Easy once you know the pattern.
8. watch() instead of watchEffect() for precise dependency tracking
Impact: Low to moderate. Good practice regardless.
The Quick Wins Checklist
Before spending time on complex optimisations, run through these in 30 minutes:
✓ Replace ref() with shallowRef() for every large array fetched from an API
✓ Add Object.freeze() to every static dropdown/option array
✓ Run Vue DevTools Performance tab — identify any component rendering > 20ms
✓ Check for inline object/function props ({ ... } or () => ... directly in template)
✓ Verify defineAsyncComponent wraps any component > 50KB
✓ Add v-memo to any v-for list with 100+ items that has selection state
✓ Open Chrome Network tab — confirm any heavy async component loads on demand
and not on initial page load
✓ Check that keep-alive has a :max value and :include list, not no arguments
Final Thoughts
Vue 3’s compiler handles most performance work automatically. The cases where manual optimisation matters are specific: large lists with independent item state, heavy components that load eagerly, computed properties with too many dependencies, and components that re-render due to unstable prop references.
The flame graph in Vue DevTools is the correct starting point — every time. The optimisations in this post are the tools. The flame graph tells you which tools to reach for.
Most of the gains in our audit came from three things: shallowRef for data, v-memo for the product table, and defineAsyncComponent for the analytics section. Everything else was incremental. Know your hot paths, fix those first, and measure after every change.
The best Vue performance optimisation is the one that removes a measured bottleneck. The second best is shallowRef on your API data arrays.
