Vue is fast by default, and most performance work on a Vue application turns out not to be about Vue at all. Before reaching for anything in this lesson, open the Performance panel and find out what is actually slow. The answer is usually an image, a network waterfall, or an algorithm — not the framework.
What follows is what to do once you have measured.
Where re-renders come from
Vue tracks dependencies per component. When a reactive value changes, only the components whose
render read that value re-render. There is no cascade down the tree, which is the main structural
difference from React and the reason there is no useMemo, no
useCallback and no React.memo equivalent to reach for.
Two practical consequences:
Passing a new object or function as a prop does not cause a re-render the way it can in React. The child re-renders when a value it reads changes.
A big component is one dependency set. A view that reads twenty refs re-renders when any of them changes. Splitting it into smaller components narrows what each one depends on, and that is usually the highest-leverage change available — a structural fix rather than a directive.
Keep work out of the render path
Lesson 6's rule, stated as a performance point: anything a template reads should be a computed, so it is calculated once and cached, not on every render.
<!-- Runs on every re-render, for every item. -->
<li v-for="reel in reels.filter(r => r.published)" :key="reel.id">// Runs when `reels` changes. Nothing else invalidates it.
const published = computed(() => reels.value.filter((r) => r.published));Same rule for anything expensive in an interpolation — date formatting, currency, sorting.
v-once and v-memo
v-once renders an element one time and never updates it again:
<footer v-once>
<p>© 2026 ReelCMS. All rights reserved.</p>
</footer>v-memo skips updating a subtree unless one of the values in its array changes:
<div v-for="reel in reels" :key="reel.id" v-memo="[reel.id, reel.status]">
<!-- Re-renders only if the id or the status changed. A views count ticking
up in the background will not touch this row. -->
<ReelRow :reel="reel" />
</div>These are escape hatches, not habits. v-memo in particular is easy to
get wrong: omit a value the subtree reads and it silently renders stale data, which is a much worse bug
than a slow list. Vue's own documentation says to reach for it only on large lists — thousands of rows
— where you have measured a problem.
shallowRef
A ref holding an object is deeply reactive: Vue walks the structure to make every nested
property observable. For a large object that is real work, done on creation and again on replacement.
shallowRef makes only the .value assignment reactive:
import { shallowRef, triggerRef } from "vue";
// A 5,000-row report we replace wholesale and never edit in place.
const report = shallowRef(null);
report.value = await api.reports(); // reactive -- the whole value changed
report.value.totals.views = 0; // NOT reactive -- nothing re-renders
// If you must mutate, tell Vue yourself.
triggerRef(report);The condition for using it is precise: a large object you replace rather than edit. API responses, parsed files, chart datasets. If you mutate nested properties and expect the UI to follow, this is the wrong tool and the bug is silent.
shallowReactive is the equivalent for reactive.
Rendering long lists
Nothing above helps with ten thousand DOM nodes, because the cost is the DOM, not Vue. There are only three real answers, in order of preference:
Paginate. The demo application's search shows twelve results a page.
Load incrementally. The feed fetches four reels at a time and prefetches the next page two slides before the end, so the scroll never stalls.
Virtualise. Render only the visible rows. vue-virtual-scroller is the
usual choice. Do this last — it complicates keyboard navigation, find-in-page and accessibility.
Observers beat scroll listeners
A pattern worth internalising, and the reason the feed is built the way it is. A
scroll listener fires on every frame of a flick — dozens of times a second — and each call
that reads offsetTop or getBoundingClientRect() forces the browser to
recalculate layout synchronously.
An IntersectionObserver is computed off the main thread and calls you only when an
element crosses a threshold you named:
const { observe } = useIntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// 0.6 rather than 0.5: at exactly half, two slides can both qualify
// mid-scroll and the active index flickers between them.
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
const idx = Number(entry.target.dataset.index);
activeIndex.value = idx;
// Prefetch a page before hitting the end, so the scroll never stalls.
if (idx >= reels.value.length - 2) loadPage();
}
});
},
{ root: scroller, threshold: [0.6] }
);The 0.6 is a detail worth noting: at exactly half, two slides can both qualify
mid-scroll and the active index flickers between them.
The same reasoning applies to ResizeObserver over a resize listener.
Cursor pagination, not offset
A backend point that decides frontend feel. skip(4000) makes the database walk and
discard four thousand rows before returning anything, so page 200 of an infinite feed is measurably
slower than page 2. A cursor turns the same query into "everything published before this timestamp",
which an index answers by seeking straight to the right place.
The feed sends { cursor, limit } rather than { page, size } for exactly
this reason. In an infinite scroll it is the difference between a feed that stays fast and one that
degrades the longer someone uses it.
Bundle size
Lesson 24 covers code splitting, which is the main lever. Beyond it:
Check what you are importing. npx vite-bundle-visualizer shows the
chunks by size. A single date library imported for one format call is a common find.
Import only what you use. Chart.js is tree-shakeable, which is why the dashboard registers each element explicitly rather than importing everything.
Prefer the platform. Intl.NumberFormat and
Intl.DateTimeFormat are built into the browser and replace most of what a formatting
library does.
Measure with Vue DevTools
The DevTools extension has a component inspector that shows exactly which component re-rendered and why. Use it before and after any change from this lesson — the most common outcome of a performance change made on instinct is that it did nothing, and the second most common is a subtle correctness bug in exchange for nothing.