# Performance

Course: [Vue.js](https://www.devtutorials.ai/learn/vue) · Section: Production Vue
Source: https://www.devtutorials.ai/learn/vue/production/performance

> Measuring before optimising, stable props, computed stability, v-once and v-memo, virtual lists for huge data, shallow reactivity and markRaw, lazy loading and bundle size, tree shaking, and checking a production build with DevTools, Lighthouse and a bundle report, and accessibility: keyboard, labels, live regions and focus.

## Fast Enough for Everyone

- Vue is fast by default. Most apps never need special tricks.
- Slow pages usually come from a few causes: huge lists, too many re-renders, or a big first download.
- Many visitors use cheap phones and slow networks. Test for them, not only for your laptop.
- **Measure first.** Change code only where the measurement says it is slow.

**Simple lesson:** measure, find the slow part, fix that part only.

### A Real-World Example

- Meera's order history has 20 000 orders.
- Making all of them deeply reactive takes time on every load.
- The list is only replaced as a whole, so deep tracking is wasted work.
- Compare `ref` and `shallowRef` below.

interface Order {
    id: number;
    dish: string;
    items: { name: string; qty: number }[];
}

function makeOrders(n: number): Order[] {
    return Array.from({ length: n }, (_, i) => ({ id: i + 1, dish: "Dosa", items: [{ name: "Dosa", qty: 2 }] }));
}

const deep = ref<Order[]>([]);
const shallow = shallowRef<Order[]>([]);
const result = ref<string>("");

function load(): void {
    const data1 = makeOrders(20000);
    const data2 = makeOrders(20000);
    let t = performance.now();
    deep.value = data1;
    deep.value.forEach((o) => o.items[0].qty);
    const deepMs = performance.now() - t;
    t = performance.now();
    shallow.value = data2;
    shallow.value.forEach((o) => o.items[0].qty);
    const shallowMs = performance.now() - t;
    result.value = \`ref: \${deepMs.toFixed(1)} ms, shallowRef: \${shallowMs.toFixed(1)} ms\`;
}
</script>

<template>
    <button @click="load">Load 20 000 orders twice</button>
    <p>{{ result }}</p>
    <p>Orders: {{ shallow.length }}</p>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

- **What happened:** reading every order through a deep `ref` was slower. Each nested object needed a proxy.
- `shallowRef` skipped that, because only `.value` itself is watched.

### What You Will Learn

1. How to measure.
2. Stable props, so fewer children re-render.
3. Computed stability.
4. `v-once` and `v-memo`.
5. Virtual lists for very long lists.
6. Shallow reactivity and `markRaw`.
7. Lazy loading and bundle size.
8. Checking a production build.
9. Keyboard, labels, focus and screen readers.

## Measure First

- **Vue DevTools** has a Performance and a Timeline tab. They show which components render and how long they take.
- The browser's **Performance** panel records everything the page does.
- **Lighthouse** (in Chrome DevTools) scores load speed on a slow phone and suggests fixes.
- `performance.now()` around a piece of code gives a quick timing, as in the first box.
- Always measure a **production build** (`npm run build` then `npm run preview`). Development builds are slower on purpose.

Never judge speed in development mode. Dev builds include warnings and extra checks, so they can be several times slower than the real thing.

## Fewer Re-Renders

### Stable Props

- A child re-renders when its props change.
- Pass each child exactly what it needs. Prefer `:active="item.id === activeId"` over `:active-id="activeId"`.
- With `activeId`, **every** child gets a new prop on each change, so every child re-renders.
- With a boolean, only the two children whose value really changed re-render.

<figure className="vue-fig ngc-fig">
<svg viewBox="0 0 640 190" role="img" aria-labelledby="fig-vue-stable" style={{ width: "100%", maxWidth: 640, height: "auto" }}>
  <title id="fig-vue-stable">Animation: when the active item changes, passing activeId makes all five rows flash as re-rendered, while passing an active boolean makes only the old and new active rows flash</title>
  <g style={{ fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>
    <text x="160" y="20" textAnchor="middle" style={{ fill: "var(--ngc-red)" }}>:active-id="activeId"</text>
    <text x="480" y="20" textAnchor="middle" style={{ fill: "var(--ngc-green)" }}>:active="id === activeId"</text>
  </g>
  <g style={{ fontFamily: "var(--mono)", fontSize: 12 }}>
    <rect x="80" y="32" width="160" height="26" rx="6" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} />
    <rect x="80" y="62" width="160" height="26" rx="6" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} />
    <rect x="80" y="92" width="160" height="26" rx="6" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} />
    <rect x="80" y="122" width="160" height="26" rx="6" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} />
    <rect x="80" y="152" width="160" height="26" rx="6" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} />
    <g opacity="0"><animate attributeName="opacity" values="0;0;1;0;0" keyTimes="0;0.4;0.45;0.7;1" dur="5s" repeatCount="indefinite" /> <rect x="80" y="32" width="160" height="26" rx="6" style={{ fill: "var(--ngc-red-soft)", stroke: "var(--ngc-red)", strokeWidth: 2 }} /> <rect x="80" y="62" width="160" height="26" rx="6" style={{ fill: "var(--ngc-red-soft)", stroke: "var(--ngc-red)", strokeWidth: 2 }} /> <rect x="80" y="92" width="160" height="26" rx="6" style={{ fill: "var(--ngc-red-soft)", stroke: "var(--ngc-red)", strokeWidth: 2 }} /> <rect x="80" y="122" width="160" height="26" rx="6" style={{ fill: "var(--ngc-red-soft)", stroke: "var(--ngc-red)", strokeWidth: 2 }} /> <rect x="80" y="152" width="160" height="26" rx="6" style={{ fill: "var(--ngc-red-soft)", stroke: "var(--ngc-red)", strokeWidth: 2 }} /></g>
    <rect x="400" y="32" width="160" height="26" rx="6" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} />
    <rect x="400" y="62" width="160" height="26" rx="6" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} />
    <rect x="400" y="92" width="160" height="26" rx="6" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} />
    <rect x="400" y="122" width="160" height="26" rx="6" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} />
    <rect x="400" y="152" width="160" height="26" rx="6" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} />
    <g opacity="0"><animate attributeName="opacity" values="0;0;1;0;0" keyTimes="0;0.4;0.45;0.7;1" dur="5s" repeatCount="indefinite" /> <rect x="400" y="62" width="160" height="26" rx="6" style={{ fill: "var(--ngc-green-soft)", stroke: "var(--ngc-green)", strokeWidth: 2 }} /> <rect x="400" y="122" width="160" height="26" rx="6" style={{ fill: "var(--ngc-green-soft)", stroke: "var(--ngc-green)", strokeWidth: 2 }} /></g>
  </g>
  <text x="160" y="186" textAnchor="middle" style={{ fill: "var(--ngc-red)", fontFamily: "var(--sans)", fontSize: 11 }}>5 rows re-render</text>
  <text x="480" y="186" textAnchor="middle" style={{ fill: "var(--ngc-green)", fontFamily: "var(--sans)", fontSize: 11 }}>2 rows re-render</text>
</svg>
<figcaption>Pass each child only what it needs, so only the changed children render again.</figcaption>
</figure>

const props = defineProps({ name: String, id: Number, activeId: Number, active: Boolean });
onUpdated(() => renders.count++);
</script>

<template>
    <li :class="{ on: props.active || props.activeId === props.id }">{{ name }}</li>
</template>
`,
    "renders.ts": `export const renders = { count: 0 };
`,
    "App.vue": `<script setup lang="ts">

const dishes = ["Dosa", "Idli", "Vada", "Pongal", "Upma"].map((name, i) => ({ id: i + 1, name }));
const activeA = ref<number>(1);
const activeB = ref<number>(1);

async function measure(which: "A" | "B"): Promise<void> {
    renders.count = 0;
    if (which === "A") activeA.value = (activeA.value % 5) + 1;
    else activeB.value = (activeB.value % 5) + 1;
    await nextTick();
    console.log(\`\${which === "A" ? "activeId prop" : "boolean prop"}: \${renders.count} rows re-rendered\`);
}
</script>

<template>
    <ul><DishRow v-for="d in dishes" :key="d.id" :id="d.id" :name="d.name" :active-id="activeA" /></ul>
    <button @click="measure('A')">Next (activeId prop)</button>
    <ul><DishRow v-for="d in dishes" :key="d.id" :id="d.id" :name="d.name" :active="d.id === activeB" /></ul>
    <button @click="measure('B')">Next (boolean prop)</button>
</template>

<style>
.on { font-weight: bold; color: #2f9e6e; }
</style>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

### Computed Stability

- Since Vue 3.4, a computed value only triggers its readers when its **result** really changes.
- `isBusy = computed(() => orders.value > 5)` triggers only when it flips between true and false.
- If a computed returns a **new object** every time, it always looks changed. Return the old object when nothing changed.

### v-once and v-memo

- `v-once` renders an element once, then skips it forever. Good for truly fixed content.
- `v-memo="[a, b]"` skips updating that element unless `a` or `b` changed.
- `v-memo` helps in very long `v-for` lists where only a few rows change. Use it rarely, and only after measuring.

const rows = Array.from({ length: 1000 }, (_, i) => ({ id: i + 1, dish: "Order " + (i + 1) }));
const selected = ref<number>(1);
const time = ref<string>("");

function pick(id: number): void {
    const t = performance.now();
    selected.value = id;
    requestAnimationFrame(() => (time.value = \`update took about \${(performance.now() - t).toFixed(1)} ms\`));
}
</script>

<template>
    <p>{{ time }}</p>
    <div style="height: 160px; overflow: auto">
      <div v-for="r in rows" :key="r.id" v-memo="[r.id === selected]" :class="{ on: r.id === selected }" @click="pick(r.id)">
        {{ r.dish }} {{ r.id === selected ? "✓" : "" }}
      </div>
    </div>
</template>

<style>
.on { background: #e6f7ec; }
</style>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

`v-memo` must list **every** value the row shows that can change. If you leave one out, that part of the row stops updating, which is a hard bug to spot.

**Simple lesson:** stable props, stable computed values, and `v-memo` only where measured.

## Very Long Lists

- Rendering 10 000 rows creates 10 000 elements. That is slow, even if Vue is fast.
- A **virtual list** renders only the rows you can see, plus a few extra.
- As you scroll, it swaps which rows are rendered. A tall spacer keeps the scrollbar right.
- Libraries such as `vue-virtual-scroller` do this well. Here is the idea in a few lines.

const all = Array.from({ length: 10000 }, (_, i) => "Order " + (i + 1));
const rowHeight = 28;
const viewHeight = 280;
const scrollTop = ref<number>(0);

const start = computed(() => Math.floor(scrollTop.value / rowHeight));
const visible = computed(() => all.slice(start.value, start.value + Math.ceil(viewHeight / rowHeight) + 2));

function onScroll(e: Event): void {
    scrollTop.value = (e.target as HTMLElement).scrollTop;
}
</script>

<template>
    <p>{{ all.length }} orders, but only {{ visible.length }} rows on the page.</p>
    <div class="viewport" :style="{ height: viewHeight + 'px' }" @scroll="onScroll">
      <div :style="{ height: all.length * rowHeight + 'px', position: 'relative' }">
        <div
          v-for="(row, i) in visible"
          :key="start + i"
          class="row"
          :style="{ position: 'absolute', top: (start + i) * rowHeight + 'px', height: rowHeight + 'px' }"
        >
          {{ row }}
        </div>
      </div>
    </div>
</template>

<style>
.viewport { overflow: auto; border: 1px solid #ccc; }
.row { left: 0; right: 0; padding: 4px 8px; border-bottom: 1px solid #eee; box-sizing: border-box; }
</style>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

### Shallow Reactivity and markRaw

- Big data that you only **replace** should live in `shallowRef`, as in the first box.
- Big objects from other libraries (a map, a chart) should be wrapped in `markRaw()`.
- Both come from **Advanced Reactivity**.

**Simple lesson:** render only what is visible, and do not make big data deeply reactive.

## A Smaller First Download

- The first download is often the biggest delay on slow networks.
- **Tree shaking:** the build removes code you never import. Import only what you use.
- **Lazy loading:** split rarely used pages and heavy components with `() => import()` and `defineAsyncComponent`.
- Check what is in your bundle before guessing.

// Code in a plain <script> block runs when the file is first loaded.
(window as unknown as { chartLoaded: boolean }).chartLoaded = true;
console.log("HeavyChart code loaded");
</script>

<template>
    <p class="chart">📈 Sales chart (pretend this file is 300 KB)</p>
</template>
`,
    "App.vue": `<script setup lang="ts">

const HeavyChart = defineAsyncComponent(() => import("./HeavyChart.vue"));
const show = ref<boolean>(false);
const atStart = ref<string>("");

onMounted(() => {
    atStart.value = String((window as unknown as { chartLoaded?: boolean }).chartLoaded ?? false);
});
</script>

<template>
    <p id="loaded">Chart code at start: {{ atStart }}</p>
    <button id="show" @click="show = true">Show chart</button>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

### Checking a Production Build

```bash
npm run build      # makes dist/, prints the size of each file
npm run preview    # serves dist/ like a real host
npx vite-bundle-visualizer   # opens a map of what fills your bundle
```

- Look for unexpectedly big libraries, and for code that every page loads but few use.
- Run Lighthouse on the preview, with a slow phone and network.

These commands run in a real project on your computer. The lab cannot build or measure network downloads, because every file is already in the preview.

**Simple lesson:** import less, lazy-load the rest, and read your bundle report.

## Usable by Everyone

- A fast app must also work with a **keyboard** and a **screen reader** (software that reads the page aloud).
- Use real elements: `<button>` for actions, `<a>`/`<RouterLink>` for navigation. A clickable `<div>` cannot be reached with **Tab**.
- Every input needs a `<label>`. Every image needs `alt` text (empty `alt=""` if it is only decoration).
- When content changes without a page load, announce it in an **`aria-live`** region.
- After a route change in an SPA, move focus to the new page's heading, so screen readers start there.
- Keep focus visible, and respect `prefers-reduced-motion` (see **Transitions and Animations**).

const cart = ref<string[]>([]);
const message = ref<string>("");
const heading = useTemplateRef<HTMLHeadingElement>("heading");

function add(dish: string): void {
    cart.value.push(dish);
    message.value = \`\${dish} added. \${cart.value.length} items in your cart.\`;
}

async function goToCart(): Promise<void> {
    await nextTick();
    heading.value?.focus();
    console.log("focus moved to:", document.activeElement?.textContent);
}
</script>

<template>
    <label for="note">Note for the kitchen</label>
    <input id="note" placeholder="Less spicy" />

    <button @click="add('Dosa')">Add Dosa</button>
    <button @click="add('Idli')">Add Idli</button>
    <div class="bad" @click="add('Vada')">Add Vada (a div: Tab skips it)</div>

    <p aria-live="polite" class="status">{{ message }}</p>

    <button @click="goToCart">Open cart</button>
    <h3 ref="heading" tabindex="-1">Your cart ({{ cart.length }})</h3>
</template>

<style>
.bad { display: inline-block; padding: 2px 6px; border: 1px dashed #999; cursor: pointer; }
.status { min-height: 1.4em; }
h3:focus { outline: 3px solid #2f9e6e; }
</style>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

- Press **Tab** in the preview: it stops on the input and the buttons, but never on the dashed "div button".
- The `aria-live` paragraph is read aloud each time its text changes. `tabindex="-1"` lets code focus the heading.

<div className="tscroll">

| Check | Tool |
|---|---|
| Keyboard only | Unplug the mouse: Tab, Shift+Tab, Enter, Space, Esc |
| Automatic rules | Lighthouse or the axe DevTools extension |
| Linting while you type | `eslint-plugin-vuejs-accessibility` |
| A real screen reader | NVDA (Windows), VoiceOver (Mac, iPhone), TalkBack (Android) |

</div>

**Simple lesson:** real buttons and labels, visible focus, and announce changes that happen without a reload.

## TL;DR

- **Measure first** = DevTools, Lighthouse, `performance.now()`, on a production build.
- **Stable props** = pass each child only the value it needs, such as a boolean.
- **Computed stability** = computed triggers only when its result changes (3.4+).
- **`v-once`** = render once, skip forever.
- **`v-memo="[deps]"`** = skip updates unless the listed values change.
- **Virtual list** = render only the visible rows of a huge list.
- **`shallowRef` / `markRaw`** = skip deep tracking for big or foreign data.
- **Lazy loading** = `() => import()` and `defineAsyncComponent`.
- **Bundle report** = `npm run build` and a visualizer show what is big.

## Common mistakes

<div className="tscroll">

| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| Optimising without measuring | Every trick helps | Measure, then fix the slow part | Complex code with no gain |
| Measuring the dev build | Dev speed = real speed | Measure `build` + `preview` | Wrong conclusions |
| Passing shared ids to every row | Props cost nothing | Pass a computed boolean | Every row re-renders |
| `v-memo` missing a value | It updates when needed | List every changing value | Rows stop updating |
| Rendering 10 000 rows | Vue is fast enough | Use a virtual list or pages | Slow scrolling and memory use |
| Deep `ref` for huge read-only data | Deep is always fine | `shallowRef` | Slow loads |
| Importing a whole library for one function | Unused code is removed anyway | Import only what you use | A bigger bundle |

</div>

## Check Yourself

## Exercises

## What to Remember

- Measure a production build before you optimise.
- Pass stable, minimal props so fewer children re-render.
- Use `v-once` and `v-memo` only where measurement shows a need.
- Render huge lists with a virtual list. Keep big data in `shallowRef`.
- Lazy-load heavy parts and read your bundle report.

Next: **Errors, Security and Deployment**
