- Understanding Vue 3's Reactivity System
- Smart Component Splitting with defineAsyncComponent
- Avoiding Unnecessary Re-renders
- v-memo: The Hidden Gem
- Computed Properties vs Watchers
- Virtual Scrolling for Large Lists
- Tree-shaking and Bundle Optimization
- Web Workers with useWebWorker
- Suspense and Streaming SSR
- Profiling and Measuring Real Impact
Vue 3 ships with a dramatically faster virtual DOM engine and a fine-grained reactivity system built on ES Proxies. Despite these improvements, poorly structured applications can still suffer from sluggish renders, memory bloat, and unnecessary re-computations. This article covers ten advanced techniques that go beyond the basics โ patterns used in production applications handling millions of users.
1. Understanding Vue 3's Reactivity System
Before optimizing, you need to understand what Vue tracks. Vue 3 uses a Proxy-based reactive system that intercepts property access and mutation. Every reactive object has an associated dependency map โ when a property is read inside a computed or watchEffect, Vue registers that dependency. When it changes, only the affected subscribers re-run.
The key insight: reactivity has a cost. Wrapping large objects in reactive() causes Vue to recursively proxy every nested property. For read-only or rarely-mutated data, use shallowRef or shallowReactive instead.
// โ Bad โ deeply proxies the entire dataset on every update
const dataset = reactive({ rows: fetchThousandsOfRows() })
// โ
Good โ only the top-level ref is reactive
const dataset = shallowRef({ rows: fetchThousandsOfRows() })
// Trigger update by replacing the whole value
dataset.value = { rows: newRows() }
markRaw() to permanently exclude objects from reactivity tracking. This is ideal for third-party class instances like chart libraries, WebSocket clients, or large configuration objects that Vue should never observe.2. Smart Component Splitting with defineAsyncComponent
Route-level code splitting is well-known, but component-level lazy loading is often overlooked. Vue 3's defineAsyncComponent lets you defer loading any component until it's actually needed โ reducing your initial bundle size significantly.
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent({
loader: () => import('./components/HeavyChart.vue'),
loadingComponent: Spinner,
errorComponent: ErrorFallback,
delay: 200, // show loading only after 200ms
timeout: 5000 // show error after 5s
})
Combine this with Vue's <Suspense> component for declarative async handling, and you have a powerful pattern for keeping initial payloads lean while delivering rich functionality on demand.
3. Avoiding Unnecessary Re-renders
Vue re-renders a component when its reactive dependencies change. But prop drilling and reactive object references are common culprits that cause cascading re-renders across the entire component tree.
The primary defense is v-once for truly static content and Object.freeze() for static data passed as props:
<!-- Static content rendered exactly once -->
<HeroSection v-once />
// Static config โ Vue won't track any of its properties
const chartConfig = Object.freeze({
colors: ['#42d392', '#647eff'],
animation: 'ease-in-out'
})
For child components that receive complex props, wrap them with defineComponent and use toRef to pass individual reactive properties rather than entire objects. This way, a child only re-renders when its specific slice of data changes.
4. v-memo: The Hidden Gem
Introduced in Vue 3.2, v-memo is one of the most underused directives. It memoizes a subtree and skips re-rendering entirely unless the specified dependencies change โ similar to React's useMemo but at the template level.
<!-- Only re-renders this row when item.id or isSelected changes -->
<div
v-for="item in list"
:key="item.id"
v-memo="[item.id, isSelected(item.id)]"
>
<ComplexRow :item="item" />
</div>
v-memo shines in large v-for lists where each row has expensive child components. For simple lists with primitive values, the overhead of memoization may outweigh its benefits โ always measure first.5. Computed Properties vs Watchers
A common mistake is using watch to derive state that should be a computed property. Computed values are lazily evaluated and aggressively cached โ they only recalculate when their specific dependencies change, and they return the cached value for every other access.
// โ Wasteful โ runs imperatively on every change
const filteredList = ref([])
watch([items, searchQuery], () => {
filteredList.value = items.value.filter(
i => i.name.includes(searchQuery.value)
)
})
// โ
Efficient โ cached until items or searchQuery changes
const filteredList = computed(() =>
items.value.filter(i => i.name.includes(searchQuery.value))
)
Reserve watch for side effects โ API calls, DOM manipulation, logging. Use watchEffect when you want automatic dependency tracking without specifying sources manually. And always clean up side effects by returning a cleanup function.
6. Virtual Scrolling for Large Lists
Rendering 10,000 DOM nodes is never fast, regardless of framework. Virtual scrolling solves this by only rendering the items currently visible in the viewport, recycling DOM nodes as the user scrolls.
The vue-virtual-scroller library provides a drop-in solution. For custom implementations, the core principle is maintaining a window of rendered items based on scroll position and item height:
<template>
<RecycleScroller
class="scroller"
:items="largeDataset"
:item-size="64"
key-field="id"
>
<template #default="{ item }">
<UserRow :user="item" />
</template>
</RecycleScroller>
</template>
This technique reduces DOM node count from tens of thousands to just the visible ~20-30 rows, cutting render time and memory usage by orders of magnitude.
7. Tree-shaking and Bundle Optimization
Vue 3's API is fully tree-shakable. However, your own code may inadvertently prevent effective tree-shaking. Avoid barrel exports (index.js files that re-export everything), and prefer named imports directly from source files.
// โ Imports entire utils bundle
import * as utils from '@/utils'
// โ
Tree-shakable โ only formatDate is bundled
import { formatDate } from '@/utils/date'
Use Rollup's bundle visualizer (rollup-plugin-visualizer) or Vite's built-in --report flag to inspect what's actually in your bundle. Common culprits are moment.js (replace with date-fns), lodash (use lodash-es or individual imports), and icon libraries importing full sets.
8. Offloading Heavy Work with Web Workers
JavaScript is single-threaded. CPU-intensive tasks โ data transformations, cryptography, image processing โ block the main thread and cause UI jank. The solution is Web Workers, which run in a separate thread.
VueUse provides a useWebWorkerFn composable that makes this seamless:
import { useWebWorkerFn } from '@vueuse/core'
const { workerFn, workerStatus } = useWebWorkerFn(
(data) => {
// Runs in a separate thread โ no UI blocking
return data.sort((a, b) => b.score - a.score)
},
{ timeout: 5000 }
)
const handleSort = async () => {
sortedData.value = await workerFn(rawData.value)
}
9. Suspense and Streaming SSR
Vue 3's <Suspense> component elegantly handles async component trees. When combined with server-side rendering, Vue 3.2+ supports streaming SSR โ sending HTML to the browser in chunks as it becomes available, rather than waiting for the full page to render.
Use renderToNodeStream (Node.js) or renderToWebStream (edge environments) instead of renderToString. This dramatically improves Time to First Byte (TTFB) and perceived load speed for data-heavy pages.
// server.js โ streaming SSR with Vite SSR
import { renderToNodeStream } from 'vue/server-renderer'
app.get('*', (req, res) => {
res.setHeader('Content-Type', 'text/html')
const stream = renderToNodeStream(vueApp)
stream.pipe(res)
})
10. Profiling and Measuring Real Impact
Optimization without measurement is guesswork. Vue DevTools' Performance tab records component render timings and highlights slow renders. Always profile in production mode โ development mode includes extra warnings and is significantly slower.
Key metrics to track are Largest Contentful Paint (LCP), Total Blocking Time (TBT), and Interaction to Next Paint (INP). Use Chrome's Performance panel to identify long tasks and correlate them with specific Vue components or composables.
Putting It All Together
Performance optimization in Vue 3 is about making deliberate trade-offs. Not every technique belongs in every application. Start by profiling to find your real bottlenecks, then apply the minimum change needed to hit your performance budget.
The highest-impact changes are usually: eliminating unnecessary reactivity on large datasets, lazy loading heavy components, and virtualizing long lists. These three alone can transform a sluggish application into a snappy one without touching the rest of your code.
Vue 3's architecture gives you the tools. Understanding when and why to use them is what separates a good Vue developer from a great one.