frontend / vue / 13_performance.md

Vue Performance — v-memo, shallowRef, markRaw, defineAsyncComponent

6 min read source

Vue Performance — v-memo, shallowRef, markRaw, defineAsyncComponent

TL;DR

Vue 3 is fast by default — the reactivity system tracks at the per-property level, so re-renders are narrowly scoped. The senior toolbox for tuning: v-memo (skip child re-render if deps unchanged), shallowRef/shallowReactive (opt out of deep tracking), markRaw (opt out entirely), defineAsyncComponent (code splitting), KeepAlive (cache off-screen components), and virtualization for long lists. Profile with the Vue DevTools Performance panel before optimizing.

Interview Q&A

Q: When does a Vue component re-render?

A: When a reactive value it reads during render changes. Vue’s per-property tracking means a component only re-renders if a value it actually used was updated — not because a sibling’s state changed, not because a parent re-rendered (with caveats — see below).

Compared to React (which re-renders by default and you optimize with memo), Vue is the inverse: components re-render only when their deps change. This is one of the framework’s biggest wins.

Q: Does a parent re-render cause child re-renders?

A: No in Vue 3 — the child re-renders only if its props or its own reactive deps change. This is different from React, where parent re-render triggers child re-render unless React.memo wraps the child.

But: if a parent’s re-render passes new prop values (even if structurally equal), the child does re-render. That’s where v-memo and stable prop references matter.

Q: v-memo — what’s it for and what does it do?

A: Skip re-rendering a subtree (and its children) when the listed dependency list is unchanged between renders.

<div v-memo="[item.id, item.updatedAt]">
  <ExpensiveItem :item="item" />
</div>

Vue compares the array on each render; if all entries are === to last render, it skips the diff/render for this whole subtree. Best on:

  • Expensive list items that only change when a specific field changes.
  • Static-ish content inside dynamic parents.

Empty deps v-memo="[]" = “never re-render after first” (use with caution; rare).

Don’t reach for v-memo by default. It’s the Vue equivalent of useMemo — micro-optimization, only after profiling shows the re-render is a real cost.

Q: shallowRef / shallowReactive for perf — when?

A: When you have a large object/array and you only care about reference changes, not deep mutations.

// Bad — deep reactive on a 10k-item array is expensive
const items = ref<Item[]>([]);

// Good — shallow; you're replacing the whole array on update
const items = shallowRef<Item[]>([]);
items.value = await fetchItems();    // triggers
items.value.push(newItem);            // doesn't trigger — but you don't care, you'll replace

The cost of reactive / ref is the Proxy traversal — small for a few keys, real for big nested structures. shallowRef saves that cost.

See 03_ref_vs_reactive.md for the API details.

Q: markRaw — when?

A: Tell Vue never reactify this object. Useful when:

  • It’s a heavy class instance (ml.Model, THREE.Scene, EditorView) that shouldn’t be Proxy’d.
  • Proxying breaks the object’s internal contracts (some libraries check identity).
  • You’re storing it in a reactive context but it’s never going to change.
const state = reactive({
  user: { name: "Ada" },
  editor: markRaw(new EditorView()),    // big object, leave alone
});

The user object is reactive; the editor is not — and Vue won’t deep-walk it.

Q: defineAsyncComponent — code splitting.

A: Lazy-load a component on first render:

const HeavyChart = defineAsyncComponent(() => import("./HeavyChart.vue"));

Bundler splits the chunk; first render fetches it. With <Suspense> parent or defineAsyncComponent({ loadingComponent, errorComponent }) for loading states.

Pair with Vue Router’s route-level lazy loading:

const routes = [
  { path: "/dashboard", component: () => import("./Dashboard.vue") },
];

Each route gets its own chunk; users only download what they navigate to.

Q: <KeepAlive> — when to cache?

A: Keep a component alive (preserve state, skip unmount/remount) when it’s hidden:

<KeepAlive :include="['UserList', 'PostList']" :max="10">
  <component :is="currentView" />
</KeepAlive>

The component receives onActivated/onDeactivated instead of onUnmounted/onMounted. Use for:

  • Tabs that have expensive state (scroll position, form input).
  • Navigation patterns where users return to a screen (back button → restored state).

Memory cost: cached components keep their state in memory. :max bounds the cache (LRU eviction).

Q: Virtualizing long lists.

A: Above ~200-500 items, render only what’s visible. Libraries:

  • vue-virtual-scroller — proven, supports dynamic heights.
  • @tanstack/vue-virtual — TanStack’s primitive (same library as react-virtual).
<RecycleScroller
  :items="items"
  :item-size="60"
  key-field="id"
  v-slot="{ item }"
>
  <ItemRow :item="item" />
</RecycleScroller>

Variable heights need measurement; static heights are cheaper. Pair with IntersectionObserver for infinite-load.

Q: Bundle size — what knobs?

A:

  • Tree-shake unused Vue features — Vue’s tree-shakeable; using import { ref } from "vue" only ships what you use.
  • Optional features (Options API, Compositions DevTools hooks) can be dropped in prod via build flags (__VUE_OPTIONS_API__: false).
  • vite-plugin-vue-devtools is dev-only; ensure it’s not bundled for prod.
  • CSS — scoped vs unscoped, atomic vs component CSS, Tailwind purge.
  • Async imports for route-level splitting, conditional features.

See ../09_build_tools/ for build-tool depth.

Q: Profiling.

A: Vue DevTools → Performance tab — record interactions, see which components re-rendered and why (onRenderTriggered gives the dep that caused it).

For deeper Chrome DevTools profiling:

  • Performance tab — flamegraphs, identify long tasks.
  • Coverage tab — unused JS/CSS.
  • Lighthouse — Core Web Vitals.

Q: Common Vue perf bugs.

A:

  • Reactive object too deep — every nested property is proxied; large data → use shallowRef/shallowReactive/markRaw.
  • Inline functions as props:onClick="() => doX()" creates a new function each render; the child sees a “changed” prop. Hoist or use stable methods.
  • v-for with key="index" — same as React; moving items confuses Vue’s diffing. Use a stable ID.
  • Computed cache invalidating on each render — a computed that reads Date.now() or non-reactive data won’t cache effectively.
  • Watching deep objects when you don’t need todeep: true walks the structure every change. Watch the specific path.
  • Hydration mismatches (SSR) — console errors in dev, expensive re-renders in prod. Guard browser-only code.

Gotchas / edge cases

  • v-memo deps array must be a true array of primitive-comparable values; objects compare by reference.
  • shallowRef with mutations doesn’t trigger — common “why isn’t this updating?” bug.
  • <KeepAlive> + components with side effects on mount — those don’t re-run on activation; use onActivated for re-init.
  • defineAsyncComponent race — if the user navigates away before load completes, Vue handles it; if your loader has side effects, guard them.
  • v-once — render the element/subtree once and never update. Cheaper than v-memo when you’re sure nothing should change.

What a senior is expected to say

  • “Vue 3 re-renders are scoped per component based on what reactive values were read during render — much narrower than React’s default. I don’t reach for memoization unless profiling shows a problem.”
  • v-memo for the rare case where a subtree is expensive and only changes on specific keys. Don’t apply broadly.”
  • shallowRef for big arrays/objects you only replace; markRaw for heavy class instances you don’t want proxied; shallowReactive for top-level reactive with nested values you don’t care about.”
  • “Route-level code splitting via () => import() in Vue Router; defineAsyncComponent for component-level. <KeepAlive> for tabs/back-nav where state preservation matters.”
  • “Virtualize lists above ~200 items. Stable keys in v-for. Hoist inline handlers to avoid prop-identity churn.”
  • “Profile with Vue DevTools Performance + onRenderTriggered to find what’s causing re-renders.”

Cross-references

Further reading