---
title: "Performance: lazy hydration, v-memo and measuring first · vuejs.school"
description: "Find out what is slow before you fix it, then work through the levers that pay off most in a Vue 3.5 or Nuxt 4.4 app, and the ones that only add risk."
canonical_url: "https://vuejs.school/community/blog/performance-lazy-hydration-v-memo-and-measuring-first"
meta:
  "og:description": "Find out what is slow before you fix it, then work through the levers that pay off most in a Vue 3.5 or Nuxt 4.4 app, and the ones that only add risk."
  "og:image": "https://vuejs.school/_og/d/a_~UGVyZm9ybWFuY2U6IGxhenkgaHlkcmF0aW9uLCB2LW1lbW8gYW5kIG1lYXN1cmluZyBmaXJzdCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~UGVyZm9ybWFuY2U6IGxhenkgaHlkcmF0aW9uLCB2LW1lbW8gYW5kIG1lYXN1cmluZyBmaXJzdA%2Cdescription_~RmluZCBvdXQgd2hhdCBpcyBzbG93IGJlZm9yZSB5b3UgZml4IGl0LCB0aGVuIHdvcmsgdGhyb3VnaCB0aGUgbGV2ZXJzIHRoYXQgcGF5IG9mZiBtb3N0IGluIGEgVnVlIDMuNSBvciBOdXh0IDQuNCBhcHAsIGFuZCB0aGUgb25lcyB0aGF0IG9ubHkgYWRkIHJpc2su%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9wZXJmb3JtYW5jZS1sYXp5LWh5ZHJhdGlvbi12LW1lbW8tYW5kLW1lYXN1cmluZy1maXJzdCI%2Cs_3yEkf3jbmWofYe_d.png"
  "og:image:alt": "Performance: lazy hydration, v-memo and measuring first · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Performance: lazy hydration, v-memo and measuring first · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~UGVyZm9ybWFuY2U6IGxhenkgaHlkcmF0aW9uLCB2LW1lbW8gYW5kIG1lYXN1cmluZyBmaXJzdCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~UGVyZm9ybWFuY2U6IGxhenkgaHlkcmF0aW9uLCB2LW1lbW8gYW5kIG1lYXN1cmluZyBmaXJzdA%2Cdescription_~RmluZCBvdXQgd2hhdCBpcyBzbG93IGJlZm9yZSB5b3UgZml4IGl0LCB0aGVuIHdvcmsgdGhyb3VnaCB0aGUgbGV2ZXJzIHRoYXQgcGF5IG9mZiBtb3N0IGluIGEgVnVlIDMuNSBvciBOdXh0IDQuNCBhcHAsIGFuZCB0aGUgb25lcyB0aGF0IG9ubHkgYWRkIHJpc2su%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9wZXJmb3JtYW5jZS1sYXp5LWh5ZHJhdGlvbi12LW1lbW8tYW5kLW1lYXN1cmluZy1maXJzdCI%2Cs_3yEkf3jbmWofYe_d.png"
  "twitter:image:alt": "Performance: lazy hydration, v-memo and measuring first · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## Measure before you change anything

There are two kinds of slow. **Load** is how long the first visit takes to show content and respond. **Updates** are how long the page takes to react when someone types, clicks or scrolls. They have different causes and different fixes, so start by finding out which one you have.

For load, run Lighthouse from the Lighthouse panel in Chrome DevTools, against a production build (`npm run build`, then `npm run preview`), never against the dev server. Write down three numbers:

- Largest Contentful Paint: when the biggest element appears.
- Total Blocking Time: how long the main thread was too busy to respond.
- Cumulative Layout Shift: how much the page jumps.

For updates, open the Performance panel, press record, do the slow thing once and stop. Chrome marks every task over 50 milliseconds with a red triangle; those are the ones a user feels.

Vue can label that recording for you. Its `app.config.performance` flag times each component's render and patch in development builds. In a plain Vite app, set it where you create the app:

*src/main.ts*

```
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)
app.config.performance = import.meta.env.DEV
app.mount('#app')
```

In Nuxt, set the same flag from a plugin. The `.client` suffix runs it in the browser only:

*app/plugins/perf.client.ts*

```
export default defineNuxtPlugin((nuxtApp) => {
  if (import.meta.dev) nuxtApp.vueApp.config.performance = true
})
```

Record again and the Timings track shows entries such as `<OrderTable> render` and `<OrderTable> patch`, so a long task now has a component name on it. The Timeline tab in Vue DevTools gives the same answer from the other side: its Performance layer lists the time each component spent rendering and updating.

Keep the numbers you wrote down. Every change below gets measured against them.

## Track fewer reactive dependencies

Vue's reactivity is deep by default. Every property you read from a `ref` holding an object goes through a proxy that records the read as a dependency. That costs almost nothing for a form. For 5,000 orders with 20 fields each, a render that shows every field makes 100,000 tracked reads, about where the [performance guide](https://vuejs.org/guide/best-practices/performance.html) says the overhead becomes noticeable.

For a big list you replace rather than edit, use `shallowRef`. Only `.value` itself is tracked, and the objects inside stay plain:

*src/composables/useOrders.ts*

```
import { shallowRef } from 'vue'

export interface Order {
  id: number
  customer: string
  total: number
  status: 'open' | 'paid'
}

export function useOrders() {
  const orders = shallowRef<Order[]>([])

  async function load() {
    const res = await fetch('/api/orders')
    orders.value = await res.json()
  }

  function markPaid(id: number) {
    // orders.value[i].status = 'paid' would not update the screen
    orders.value = orders.value.map((o): Order => (o.id === id ? { ...o, status: 'paid' } : o))
  }

  return { orders, load, markPaid }
}
```

The trade is that you must treat the list as immutable. Pushing into it or changing a field does nothing visible; only assigning a new array does. That rule pays off again in the next section.

The other cheap win is stable props. A row that receives `:active-id="activeId"` re-renders every time `activeId` changes, in every row. Passing `:active="order.id === activeId"` means only the two rows whose answer changed update.

Vue 3.6, in beta (3.6.0-beta.17 is the latest), rebuilds this reactivity engine on alien-signals, and its [release notes](https://github.com/vuejs/core/releases/tag/v3.6.0-beta.1) say the refactor significantly improves performance and memory use. The advice here stays the same: a render that reads fewer reactive values still does less work.

## Skip re-renders with v-once and v-memo

`v-once` renders an element and its children once, then treats them as static. Use it for content that comes from data but never changes after mount, such as a terms text loaded with the page.

`v-memo` takes an array of values and skips the whole subtree, even creating its virtual DOM nodes, while every value is the same as last time. The Vue docs call it a micro optimisation for large `v-for` lists, more than about 1,000 rows, and it must sit on the same element as the `v-for`:

*src/components/OrderTable.vue*

```
<script setup lang="ts">
import type { Order } from '../composables/useOrders'

defineProps<{ orders: Order[]; selectedId: number | null }>()
defineEmits<{ select: [id: number] }>()
</script>

<template>
  <ul>
    <li
      v-for="order in orders"
      :key="order.id"
      v-memo="[order, order.id === selectedId]"
      :aria-current="order.id === selectedId"
    >
      <button type="button" @click="$emit('select', order.id)">{{ order.customer }}</button>
      <span>{{ order.total }} · {{ order.status }}</span>
    </li>
  </ul>
</template>
```

Selecting a row now updates two rows, not 5,000. Including `order` itself in the array works because `markPaid` above swaps in a new object whenever an order changes, so a changed order fails the comparison and re-renders.

## Render only what is on screen

No directive helps when the page holds 20,000 DOM nodes. The browser has to lay them out regardless. Virtual scrolling renders only the rows in view plus a few spare, and fakes the full height with an inner wrapper. VueUse 14.3 ships `useVirtualList`, which needs to know each row's height in advance:

*src/components/OrderList.vue*

```
<script setup lang="ts">
import { toRef } from 'vue'
import { useVirtualList } from '@vueuse/core'
import type { Order } from '../composables/useOrders'

const props = defineProps<{ orders: Order[] }>()

const { list, containerProps, wrapperProps } = useVirtualList(toRef(props, 'orders'), {
  itemHeight: 48,
  overscan: 10,
})
</script>

<template>
  <div v-bind="containerProps" style="height: 480px">
    <div v-bind="wrapperProps">
      <div v-for="{ data, index } in list" :key="data.id" style="height: 48px">
        {{ index + 1 }}. {{ data.customer }}
      </div>
    </div>
  </div>
</template>
```

`overscan: 10` is the number of spare rows rendered above and below the view. `itemHeight` must match the real row height, or you get extra whitespace or jitter when you scroll to the bottom. It also accepts a function of the row's index, for rows whose heights differ but are known in advance. For anything more, the VueUse docs suggest `@tanstack/vue-virtual`.

## Split off code the first screen does not need

The first three levers fix updates. If Lighthouse pointed at load instead, the biggest win is usually code the first screen does not need. `defineAsyncComponent` puts a component and its imports in a separate chunk, fetched the first time it renders. Route components deserve the same treatment, with `component: () => import('./pages/ReportsPage.vue')` in your router.

## Delay hydration until a component is needed

Server-rendered pages have a second load cost: hydration, where Vue walks the server HTML and attaches the app to it. Vue 3.5 can delay that per component with the `hydrate` option of `defineAsyncComponent`:

*src/components/ProductPage.ts*

```
import { defineAsyncComponent, hydrateOnVisible } from 'vue'

export const SalesChart = defineAsyncComponent(() => import('./SalesChart.vue'))

export const ReviewList = defineAsyncComponent({
  loader: () => import('./ReviewList.vue'),
  hydrate: hydrateOnVisible(),
})
```

`SalesChart` is only split off, as in the last section. `ReviewList` also gets a strategy: Vue leaves its server HTML alone until it scrolls into view. Its chunk still downloads while the page hydrates; only the hydration work waits.

Nuxt wraps the same strategies in props on any `Lazy`-prefixed component. The [components docs](https://nuxt.com/docs/4.x/directory-structure/app/components) list seven: `hydrate-on-visible`, `hydrate-on-idle`, `hydrate-on-interaction`, `hydrate-on-media-query`, `hydrate-after`, `hydrate-when` and `hydrate-never`.

*app/pages/products/\[id].vue*

```
<template>
  <main>
    <ProductHero />
    <LazyReviewList hydrate-on-visible />
    <LazyNewsletterForm hydrate-on-interaction="focusin" />
    <LazySiteFooter hydrate-never />
  </main>
</template>
```

The server still renders all four, so the content is in the HTML for readers and search engines. Unlike Vue's own option, Nuxt holds back the JavaScript too: each chunk downloads only when its trigger fires.

The docs list three limits and one piece of advice:

- The prop has to be written in the template, not spread with `v-bind`.
- It only works in single-file components, and not on components imported directly from `#components`.
- Any prop change hydrates the component at once.
- Content in the first screen should never wait.

## Size your images

On many pages the Largest Contentful Paint element is an image, and the fix is the file, not the framework. Give every image a `width` and `height` so the page does not jump, serve a size close to the slot it fills, add `loading="lazy"` to images below the first screen, and never to the hero image. In Nuxt, the Nuxt Image module does the resizing: `<NuxtImg src="/hero.jpg" sizes="100vw sm:50vw md:400px" />` produces a `srcset` for each width.

## Do not memoise before a profile asks for it

Premature memoisation is the common mistake. Each `v-memo` is a list of dependencies you now maintain by hand, and a wrong list fails silently. Here is the table from earlier with the obvious-looking array:

*src/components/OrderTable.vue**Breaks***

```
<li v-for="order in orders" :key="order.id" v-memo="[order.id === selectedId]">
  <span>{{ order.total }} · {{ order.status }}</span>
</li>
```

Call `markPaid` and the row keeps saying "open", because nothing in the array changed. No error, no warning. The fix is to list everything the row shows, or the object that holds it:

*src/components/OrderTable.vue**Works***

```
<li v-for="order in orders" :key="order.id" v-memo="[order, order.id === selectedId]">
  <span>{{ order.total }} · {{ order.status }}</span>
</li>
```

The same goes for `shallowRef` on a ten-field form, which only trades deep reactivity for bugs, and for wrapping cheap expressions in `computed` to make them "faster". If the profile did not point at it, leave it.

**Check it stuck**3 questions from the Performance and Vapor Mode quiz.

[**Practice now**](https://vuejs.school/learn/advanced/performance-and-vapor-mode?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 7 min read**Vue 3.6 and Vapor Mode: where things stand**](https://vuejs.school/community/blog/vue-3-6-and-vapor-mode-where-things-stand) [Deep dive · 7 min read**Vapor Mode, explained without the hype**](https://vuejs.school/community/blog/vapor-mode-explained-without-the-hype)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/performance-lazy-hydration-v-memo-and-measuring-first#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-07-07","description":"Find out what is slow before you fix it, then work through the levers that pay off most in a Vue 3.5 or Nuxt 4.4 app, and the ones that only add risk.","name":"Performance: lazy hydration, v-memo and measuring first","url":"https://vuejs.school/community/blog/performance-lazy-hydration-v-memo-and-measuring-first","breadcrumb":{"@id":"https://vuejs.school/community/blog/performance-lazy-hydration-v-memo-and-measuring-first#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/performance-lazy-hydration-v-memo-and-measuring-first"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/performance-lazy-hydration-v-memo-and-measuring-first#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-07-07","description":"Find out what is slow before you fix it, then work through the levers that pay off most in a Vue 3.5 or Nuxt 4.4 app, and the ones that only add risk.","headline":"Performance: lazy hydration, v-memo and measuring first","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~UGVyZm9ybWFuY2U6IGxhenkgaHlkcmF0aW9uLCB2LW1lbW8gYW5kIG1lYXN1cmluZyBmaXJzdCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~UGVyZm9ybWFuY2U6IGxhenkgaHlkcmF0aW9uLCB2LW1lbW8gYW5kIG1lYXN1cmluZyBmaXJzdA%2Cdescription_~RmluZCBvdXQgd2hhdCBpcyBzbG93IGJlZm9yZSB5b3UgZml4IGl0LCB0aGVuIHdvcmsgdGhyb3VnaCB0aGUgbGV2ZXJzIHRoYXQgcGF5IG9mZiBtb3N0IGluIGEgVnVlIDMuNSBvciBOdXh0IDQuNCBhcHAsIGFuZCB0aGUgb25lcyB0aGF0IG9ubHkgYWRkIHJpc2su%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9wZXJmb3JtYW5jZS1sYXp5LWh5ZHJhdGlvbi12LW1lbW8tYW5kLW1lYXN1cmluZy1maXJzdCI%2Cs_3yEkf3jbmWofYe_d.png","@type":["Article","BlogPosting"],"articleSection":["Deep dive"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/performance-lazy-hydration-v-memo-and-measuring-first#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/performance-lazy-hydration-v-memo-and-measuring-first#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/performance-lazy-hydration-v-memo-and-measuring-first#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Performance: lazy hydration, v-memo and measuring first","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~UGVyZm9ybWFuY2U6IGxhenkgaHlkcmF0aW9uLCB2LW1lbW8gYW5kIG1lYXN1cmluZyBmaXJzdCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~UGVyZm9ybWFuY2U6IGxhenkgaHlkcmF0aW9uLCB2LW1lbW8gYW5kIG1lYXN1cmluZyBmaXJzdA%2Cdescription_~RmluZCBvdXQgd2hhdCBpcyBzbG93IGJlZm9yZSB5b3UgZml4IGl0LCB0aGVuIHdvcmsgdGhyb3VnaCB0aGUgbGV2ZXJzIHRoYXQgcGF5IG9mZiBtb3N0IGluIGEgVnVlIDMuNSBvciBOdXh0IDQuNCBhcHAsIGFuZCB0aGUgb25lcyB0aGF0IG9ubHkgYWRkIHJpc2su%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9wZXJmb3JtYW5jZS1sYXp5LWh5ZHJhdGlvbi12LW1lbW8tYW5kLW1lYXN1cmluZy1maXJzdCI%2Cs_3yEkf3jbmWofYe_d.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~UGVyZm9ybWFuY2U6IGxhenkgaHlkcmF0aW9uLCB2LW1lbW8gYW5kIG1lYXN1cmluZyBmaXJzdCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~UGVyZm9ybWFuY2U6IGxhenkgaHlkcmF0aW9uLCB2LW1lbW8gYW5kIG1lYXN1cmluZyBmaXJzdA%2Cdescription_~RmluZCBvdXQgd2hhdCBpcyBzbG93IGJlZm9yZSB5b3UgZml4IGl0LCB0aGVuIHdvcmsgdGhyb3VnaCB0aGUgbGV2ZXJzIHRoYXQgcGF5IG9mZiBtb3N0IGluIGEgVnVlIDMuNSBvciBOdXh0IDQuNCBhcHAsIGFuZCB0aGUgb25lcyB0aGF0IG9ubHkgYWRkIHJpc2su%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9wZXJmb3JtYW5jZS1sYXp5LWh5ZHJhdGlvbi12LW1lbW8tYW5kLW1lYXN1cmluZy1maXJzdCI%2Cs_3yEkf3jbmWofYe_d.png"}]}
```