Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Performance Optimisation

Lazy routes, async components, v-memo, shallowRef and avoiding needless re-renders.

Vue is fast by default. Most apps never need tuning, and premature optimisation makes code harder to read. But when a screen shows thousands of rows, or a big bundle makes the first load slow, a handful of techniques fix nearly every problem.

Performance work comes in two kinds:

Problem Symptom Tools
Load performance Slow first visit, big JavaScript download Lazy routes, async components, tree-shaking
Update performance Typing or clicking feels laggy computed, v-memo, shallowRef, v-once, virtual lists, stable props

Every demo below measures its effect live in your browser, because the first rule of performance work is: measure before and after.

Measure first

Before changing anything, find out what's actually slow:

  • Vue DevTools → Performance / Timeline: shows which components re-rendered and how long each took.
  • Browser DevTools → Performance: record an interaction and look for long tasks (yellow bars over 50 ms).
  • app.config.performance = true in development: Vue adds component render and patch timings to the browser's performance timeline.
  • Lighthouse for load performance: bundle size, time to interactive.

A component that re-renders often isn't necessarily a problem. Only optimise what you've measured to be slow. Re-rendering a 20-row list costs microseconds.

computed vs methods in templates

A method called from the template runs on every re-render of the component, even if its inputs didn't change. A computed is cached and only recalculates when one of its reactive dependencies changes.

// ❌ runs the filter on every render: typing in any input, every clock tick…
function openCountMethod() {
    return tasks.value.filter((t) => !t.done).length;
}

// ✅ runs once, then again only when `tasks` changes
const openCount = computed(() => tasks.value.filter((t) => !t.done).length);

This component re-renders every second because of a clock. Watch how often each version recalculates, then type in the box:

v-memo: skip unchanged rows

When one value changes, every row in a v-for that reads it re-renders. Selecting one row in a 2,000-row list re-renders all 2,000 rows to decide which one is highlighted.

v-memo takes an array of dependencies. Vue skips re-rendering a row entirely while that array is unchanged:

<div v-for="row in rows" :key="row.id" v-memo="[row.id === selectedId]">
    {{ row.title }} · {{ row.priority }}
</div>

When selectedId changes, only two rows' memo arrays change (the old selection and the new one), so only those two re-render. Click each button and compare the counts:

The memo array must include everything the row displays that can change. If a row shows row.done but v-memo only lists the selection, ticking a task off won't update the screen. Use v-memo only on large lists you've measured to be slow.

shallowRef for large data

ref() makes its value deeply reactive: every nested object is wrapped in a reactive proxy the first time it's read. For 50,000 rows from an API that's 50,000 proxies, which takes time and memory.

If you only ever replace the data (fetch → assign) rather than change individual items, use shallowRef(). Only .value itself is reactive:

import { shallowRef } from 'vue';

const tasks = shallowRef([]);

async function load() {
    const res = await fetch('/api/demo/tasks');
    tasks.value = (await res.json()).data;   // ✅ assigning .value triggers an update
}

// ❌ mutating inside doesn't trigger anything:
tasks.value[0].done = true;
// ✅ replace instead (or call triggerRef(tasks) after mutating)
tasks.value = tasks.value.map((t, i) => (i === 0 ? { ...t, done: true } : t));

Load the same rows into each and compare:

shallowReactive() and markRaw() work the same way. markRaw is useful for large third-party objects (a map instance, a chart, an editor) that should never be made reactive at all.

Lazy-loading routes and components

By default your bundler puts every component into one JavaScript file, so users download the admin settings page before they can see the homepage. Dynamic import() splits code into separate chunks that download only when needed.

For routes, this is one change per route:

// router/index.js
const routes = [
    { path: '/', component: HomePage },                                  // in the main bundle
    { path: '/reports', component: () => import('./pages/Reports.vue') }, // separate chunk, loaded on visit
    { path: '/settings', component: () => import('./pages/Settings.vue') },
];

For a heavy component inside a page, such as a chart library or a rich text editor, use defineAsyncComponent:

import { defineAsyncComponent } from 'vue';

const HeavyChart = defineAsyncComponent({
    loader: () => import('./HeavyChart.vue'),
    loadingComponent: ChartSkeleton,   // shown while downloading
    delay: 150,                        // …but only if it takes longer than 150 ms
    errorComponent: ChartError,        // shown if the download fails
    timeout: 10000,
});

The chart's code below isn't downloaded until you first show it, and it's only downloaded once:

<Suspense>: one loading state for async components

<Suspense> is still marked experimental in Vue 3.4 and 3.5. It works and Nuxt relies on it, but its API may change.

A component whose setup() is async (or uses top-level await in <script setup>) can't render until its promise resolves. Wrap it, or a whole tree of such components, in <Suspense> and Vue shows a single fallback until every async child is ready, instead of each component managing its own spinner:

<!-- ProjectSummary.vue: top-level await makes setup async -->
<script setup>
const response = await fetch('/api/demo/projects');
const { data: projects } = await response.json();
</script>

<!-- Parent -->
<Suspense>
    <ProjectSummary />
    <template #fallback>Loading projects…</template>
</Suspense>

The demo below wraps two async components that call this site's API with different artificial delays. Suspense waits for the slower one, then shows both at once. Errors thrown in async setup go to onErrorCaptured in the parent.

v-once and v-show

v-once renders an element once and then treats it as static. It's useful for content that never changes after the first render. v-show keeps an element in the DOM and toggles its display, which is cheaper than v-if when something is toggled often. Click the button, then remove v-once and run again:

Keep props stable

A child component re-renders when one of its props changes. Two common mistakes make props change on every render even when the data didn't:

<!-- ❌ a new object and a new function are created on every parent render -->
<TaskRow v-for="t in tasks" :key="t.id" :task="t"
         :options="{ compact: true }"
         @select="() => select(t.id)" />

<!-- ✅ pass primitives; let the child emit the id -->
<TaskRow v-for="t in tasks" :key="t.id" :task="t" compact
         :selected="t.id === selectedId"
         @select="select" />

Passing :selected="t.id === selectedId" (a boolean) instead of :selected-id="selectedId" means only the two rows whose boolean flipped re-render when the selection changes, not every row.

Very long lists: virtual scrolling

For 10,000+ rows, even a fast render is too much DOM. A virtual list renders only the rows currently visible (about 30) plus a small buffer, and swaps their content as you scroll. Use a library rather than writing your own:

<script setup>
import { RecycleScroller } from 'vue-virtual-scroller';
</script>

<template>
    <RecycleScroller :items="tasks" :item-size="36" key-field="id" v-slot="{ item }" style="height: 600px">
        <TaskRow :task="item" />
    </RecycleScroller>
</template>

Server-side pagination (see Search, Filter & Paginate) is often the simpler fix: users rarely need 10,000 rows at once.

Production build checklist

  • Build in production mode (vite build). Development builds include warnings and devtools hooks, and are several times slower.
  • Import only what you use. import { debounce } from 'lodash-es' lets the bundler drop everything else.
  • Check bundle size with npx vite-bundle-visualizer and look for unexpectedly large dependencies.
  • Lazy-load routes other than the landing page.
  • Serve assets compressed and cached. Vite's hashed filenames (app-3f9a1c.js) make long cache lifetimes safe.

Key takeaways

  • Measure first with Vue DevTools and the browser's Performance panel. Most components never need tuning.
  • Use computed for derived data: it's cached, whereas a template method runs on every render.
  • v-memo skips re-rendering list rows whose dependencies didn't change, which is a big win for large selectable lists.
  • shallowRef and markRaw avoid deep reactivity for large datasets you replace wholesale.
  • Lazy-load routes with () => import() and heavy components with defineAsyncComponent. <Suspense> (experimental) gives a tree of async components one shared loading state.
  • Keep props stable (primitives, no inline objects or functions), and virtualise or paginate very long lists.