Vue 3 Performance 2026 - Optimizing rendering from component to bundle

Posted on: 4/21/2026 12:15:55 AM

Table of contents

  1. 1. Understanding the Vue 3 Rendering Pipeline — where are the bottlenecks?
  2. 2. Vue 3.6 Vapor Mode — removing the Virtual DOM
    1. 2.1 How Vapor Mode works
    2. 2.2 Enabling Vapor Mode
      1. Vapor Mode adoption strategy
    3. 2.3 Current limitations
  3. 3. Reactivity Fine-tuning — reducing tracking cost
    1. 3.1 shallowRef and shallowReactive
      1. When to use shallowRef?
    2. 3.2 computed vs methods — cache or no cache?
    3. 3.3 Avoid inline objects in templates
  4. 4. Template-level Optimization — v-once, v-memo, and KeepAlive
    1. 4.1 v-once — render once, only
    2. 4.2 v-memo — conditional memoization
    3. 4.3 KeepAlive — cache component instances
      1. KeepAlive and memory
  5. 5. Virtual Scrolling — rendering thousands of items smoothly
    1. 5.1 Implementation with @tanstack/vue-virtual
    2. 5.2 Combine virtual scrolling with shallowRef
  6. 6. Lazy Loading and Code Splitting
    1. 6.1 Route-level code splitting
    2. 6.2 Component-level lazy loading with defineAsyncComponent
    3. 6.3 Prefetch and preload strategies
      1. Prefetch with Speculation Rules API
  7. 7. Bundle Optimization — reducing what the browser receives
    1. 7.1 Analyze bundles with rollup-plugin-visualizer
    2. 7.2 Import correctly — effective tree shaking
    3. 7.3 Configure chunking strategy
  8. 8. Memory Management — preventing leaks
    1. 8.1 Clean up side effects in onUnmounted
    2. 8.2 AbortController for async operations
    3. 8.3 Watch cleanup
      1. Detecting memory leaks
  9. 9. SSR and SSG — optimizing for First Contentful Paint
    1. 9.1 Nuxt 4 Hybrid Rendering
    2. 9.2 Selective Hydration
  10. 10. Performance Profiling — measure before you tune
    1. 10.1 Vue DevTools Performance Tab
    2. 10.2 Lighthouse and Core Web Vitals
    3. 10.3 Custom performance markers
  11. 11. Vue 3 production optimization checklist
  12. 12. Conclusion
    1. Keep an eye on Vue 3.6 stable
    2. References

Have you ever deployed a Vue 3 app to production only to find the home page takes over 3 seconds to become interactive? A list of 10,000 items lagging when you scroll? A bundle that balloons to 2MB despite using only a handful of components? These are the common pain points most Vue developers face — and Vue 3.6 with Vapor Mode, combined with a set of optimization techniques, can solve them for good.

This post is not just theory. We'll go deep, from how Vue renders components, why the Virtual DOM can become a bottleneck, to every technique with real code — turning your Vue 3 app from "runs" into "runs fast".

~97% Rendering improvement with Vapor Mode
100ms Mount 100K components (Vapor)
60-80% Bundle-size reduction with tree shaking
10x Scroll speed with virtual list

1. Understanding the Vue 3 Rendering Pipeline — where are the bottlenecks?

Before optimizing, you need to understand how Vue 3 renders components. Each time state changes, Vue runs through a multi-step pipeline — and every step has a cost.

graph LR
    A[State Change] --> B[Trigger Reactivity]
    B --> C[Re-run render function]
    C --> D[Generate new VNode tree]
    D --> E[Diff old vs new VNode]
    E --> F[Patch DOM]

    style A fill:#e94560,stroke:#fff,color:#fff
    style B fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style C fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style E fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style F fill:#e94560,stroke:#fff,color:#fff

Vue 3 rendering pipeline — from state change to DOM update

Within this pipeline, Generate VNode and Diff VNode are the most expensive steps. With a large component tree (thousands of nodes), creating VNode objects, comparing properties, then deciding patches — all of it consumes CPU and memory.

Vue 3 is already much faster than Vue 2 thanks to compiler hints like patch flags, static hoisting, and block tree. But the Virtual DOM is still an intermediate layer — and Vapor Mode in Vue 3.6 removes it entirely.

2. Vue 3.6 Vapor Mode — removing the Virtual DOM

Vapor Mode is the biggest feature in Vue 3.6 (currently in beta). Instead of compiling templates into render functions that return VNodes, Vapor Mode compiles directly into imperative DOM operations — similar to how Svelte and Solid.js work.

2.1 How Vapor Mode works

graph TB
    subgraph Traditional["Traditional Mode"]
        T1[Template] --> T2[Render Function]
        T2 --> T3[VNode Tree]
        T3 --> T4[Diff Algorithm]
        T4 --> T5[DOM Patches]
    end

    subgraph Vapor["Vapor Mode"]
        V1[Template] --> V2[Compiled DOM Operations]
        V2 --> V3[Direct DOM Updates]
    end

    style T1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style T2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style T3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style T4 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style T5 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style V1 fill:#e94560,stroke:#fff,color:#fff
    style V2 fill:#e94560,stroke:#fff,color:#fff
    style V3 fill:#e94560,stroke:#fff,color:#fff

Traditional Mode vs Vapor Mode — Vapor skips VNode creation and diffing entirely

In Traditional Mode, a simple component rendering a count variable creates a VNode object, compares it with the old VNode, then updates the text node. In Vapor Mode, the compiler analyzes templates at build time, knows exactly which DOM node depends on which variable, and emits code that updates the DOM directly — no intermediate.

2.2 Enabling Vapor Mode

Vapor Mode works at the component level — you can mix traditional and Vapor components in the same app:

<!-- CounterVapor.vue -->
<!-- Add the vapor attribute to enable Vapor Mode for this component -->
<script setup vapor>
import { ref } from 'vue/vapor'

const count = ref(0)
const increment = () => count.value++
</script>

<template>
  <button @click="increment">Count: {{ count }}</button>
</template>

Vite plugin configuration:

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      features: {
        vaporMode: true // Allows components with the "vapor" attribute
      }
    })
  ]
})

Vapor Mode adoption strategy

Don't move the whole app to Vapor Mode at once. Start with leaf components that re-render often (list items, cards, badges) — that's where Vapor Mode yields the biggest gains by cutting per-re-render overhead. Root components (layout, page) rarely re-render so the benefit is marginal.

2.3 Current limitations

As of April 2026, Vapor Mode is still in beta (v3.6.0-beta). Some caveats:

  • Incomplete support for <Transition> and <TransitionGroup>
  • Some UI library plugins (Vuetify, PrimeVue) may not be compatible
  • DevTools profiling for Vapor components is still being improved
  • Not recommended for production until stable release

3. Reactivity Fine-tuning — reducing tracking cost

Vue 3's reactivity system uses Proxy to track dependencies. By default, ref() and reactive() deep-track the entire nested object. With large datasets, this tracking cost becomes significant.

3.1 shallowRef and shallowReactive

import { shallowRef, triggerRef } from 'vue'

// Deep ref — tracks every property in every item
const items = ref(largeDataset) // 10,000 items, 20 properties each
// Vue creates Proxies for: items.value, items.value[0], items.value[0].name, ...

// Shallow ref — only tracks reassignments of items.value
const items = shallowRef(largeDataset)
// Vue only proxies items.value — does NOT descend into each item

// Updating:
items.value[0].name = 'Updated' // Does NOT trigger re-render (shallow)
items.value = [...items.value]  // Triggers re-render (reference change)

// Or use triggerRef to force an update without creating a new array:
items.value[0].name = 'Updated'
triggerRef(items) // Force re-render

When to use shallowRef?

Use shallowRef when data has many nested objects that you typically replace wholesale (new API fetch, pagination). Don't use it when you need reactivity on individual fields for form binding — v-model on a shallowRef child property will not work.

3.2 computed vs methods — cache or no cache?

// Method — called on every component re-render
const getFilteredItems = () => {
  return items.value.filter(item => item.active)
}

// Computed — only recalculates when dependencies change
const filteredItems = computed(() => {
  return items.value.filter(item => item.active)
})
// If items didn't change, computed returns cached result — no re-filter

With a 10,000-item list, the difference between method and computed can be tens of milliseconds per re-render — especially when the parent re-renders but the child data is unchanged.

3.3 Avoid inline objects in templates

<!-- Creates a new object on every render -> child component always re-renders -->
<ChildComponent :style="{ color: 'red', fontSize: '14px' }" />
<ChildComponent :config="{ pageSize: 20, sortBy: 'name' }" />

<!-- Declare outside the template or use computed -->
<script setup>
const cardStyle = { color: 'red', fontSize: '14px' }
const tableConfig = computed(() => ({
  pageSize: pageSize.value,
  sortBy: sortField.value
}))
</script>

<ChildComponent :style="cardStyle" />
<ChildComponent :config="tableConfig" />

4. Template-level Optimization — v-once, v-memo, and KeepAlive

4.1 v-once — render once, only

Use v-once for completely static subtrees (header, footer, disclaimer text). Vue skips the subtree entirely on every subsequent re-render.

<template>
  <!-- Static header — never changes -->
  <div v-once class="terms-of-service">
    <h2>Terms of Service</h2>
    <p>Lots of static content here...</p>
    <!-- 50+ paragraphs -->
  </div>

  <!-- Dynamic section -->
  <UserDashboard :user="currentUser" />
</template>

4.2 v-memo — conditional memoization

v-memo lets you specify a dependency array — similar to React.memo but at the template level. Extremely useful for list items inside v-for:

<template>
  <div v-for="item in list" :key="item.id"
       v-memo="[item.id === selectedId, item.name, item.status]">
    <!-- Complex component with many children -->
    <ItemCard :item="item" :selected="item.id === selectedId" />
    <ItemActions :status="item.status" />
    <ItemMetadata :item="item" />
  </div>
</template>

When scrolling through a 1,000-item list while only changing selectedId, Vue only re-renders 2 items (old unselected + new selected) instead of all 1,000.

4.3 KeepAlive — cache component instances

<template>
  <!-- Cache up to 5 most recent tabs -->
  <KeepAlive :max="5" :include="['Dashboard', 'Settings', 'Profile']">
    <component :is="currentTabComponent" />
  </KeepAlive>
</template>

KeepAlive and memory

Always set :max on KeepAlive. Unbounded = keep every component instance in memory -> memory leak. The max prop uses LRU (Least Recently Used) — the least-used component gets destroyed first.

5. Virtual Scrolling — rendering thousands of items smoothly

When a list has thousands of items, rendering them all into the DOM is the fastest way to kill performance. Virtual scrolling only renders items visible in the viewport — dropping DOM nodes from thousands to a few dozen.

graph TB
    subgraph Normal["Regular rendering"]
        N1["10,000 DOM nodes"]
        N2["Each node = event listener + style calc"]
        N3["Scroll lag 200-500ms"]
    end

    subgraph Virtual["Virtual Scrolling"]
        V1["~30 visible DOM nodes"]
        V2["Recycle on scroll"]
        V3["Smooth scroll at 16ms/frame"]
    end

    style N1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style N2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style N3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style V1 fill:#e94560,stroke:#fff,color:#fff
    style V2 fill:#e94560,stroke:#fff,color:#fff
    style V3 fill:#e94560,stroke:#fff,color:#fff

DOM node counts: regular rendering vs virtual scrolling

5.1 Implementation with @tanstack/vue-virtual

<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'

const parentRef = ref<HTMLElement | null>(null)

// Large dataset — 50,000 items
const items = shallowRef(generateItems(50_000))

const virtualizer = useVirtualizer({
  count: items.value.length,
  getScrollElement: () => parentRef.value,
  estimateSize: () => 60, // Estimated item height (px)
  overscan: 5, // Render 5 extra items above/below viewport for smooth scroll
})
</script>

<template>
  <div ref="parentRef" style="height: 600px; overflow-y: auto;">
    <div :style="{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }">
      <div
        v-for="row in virtualizer.getVirtualItems()"
        :key="row.key"
        :style="{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          height: `${row.size}px`,
          transform: `translateY(${row.start}px)`
        }"
      >
        <ItemRow :item="items[row.index]" />
      </div>
    </div>
  </div>
</template>
Library Size Dynamic height Grid support Notes
@tanstack/vue-virtual ~5KB gzip Yes Yes Headless, flexible, actively maintained
vue-virtual-scroller ~8KB gzip Yes No Simple API, good for basic lists
vue-virtual-scroll-grid ~4KB gzip No Yes Specialized for grid layouts

5.2 Combine virtual scrolling with shallowRef

This is the single most important combo for large lists:

// Deep reactive + no virtual scroll = disaster
const items = ref(hugeArray) // Proxy for 50,000 objects

// Shallow ref + virtual scroll = optimal
const items = shallowRef(hugeArray) // No deep proxy
// + virtual scroll only renders 20-30 visible items

// Updating a single item:
const updateItem = (index: number, newData: Partial<Item>) => {
  const updated = [...items.value]
  updated[index] = { ...updated[index], ...newData }
  items.value = updated // Triggers re-render, virtual scroll only renders visible items
}

6. Lazy Loading and Code Splitting

Bundle size directly affects Time to Interactive (TTI). With Vue 3 + Vite, code splitting is essentially free — you just need to place dynamic imports in the right spots.

6.1 Route-level code splitting

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      // Lazy load — separate chunk per route
      component: () => import('@/pages/HomePage.vue')
    },
    {
      path: '/dashboard',
      component: () => import('@/pages/DashboardPage.vue'),
      children: [
        {
          path: 'analytics',
          // Separate chunk for sub-route
          component: () => import('@/pages/dashboard/AnalyticsPage.vue')
        },
        {
          path: 'settings',
          component: () => import('@/pages/dashboard/SettingsPage.vue')
        }
      ]
    },
    {
      path: '/admin',
      // Shared chunk for the admin group
      component: () => import(
        /* webpackChunkName: "admin" */
        '@/pages/AdminLayout.vue'
      )
    }
  ]
})

6.2 Component-level lazy loading with defineAsyncComponent

import { defineAsyncComponent } from 'vue'

// Heavy component — only load when needed
const HeavyChart = defineAsyncComponent({
  loader: () => import('@/components/HeavyChart.vue'),
  loadingComponent: ChartSkeleton,  // Skeleton while loading
  delay: 200,                       // Wait 200ms before showing loading state
  timeout: 10000,                   // 10s timeout
  errorComponent: ChartError,       // Shown on load error
})

// Combine with v-if — only load when the user really needs it
const showChart = ref(false)
<template>
  <button @click="showChart = true">Show chart</button>

  <!-- HeavyChart.vue is only downloaded when showChart = true -->
  <HeavyChart v-if="showChart" :data="chartData" />
</template>

6.3 Prefetch and preload strategies

// Prefetch on hover — smoother UX
const prefetchDashboard = () => {
  import('@/pages/DashboardPage.vue')
}

// In the template
// <router-link to="/dashboard" @mouseenter="prefetchDashboard">
//   Dashboard
// </router-link>

// Or use router.beforeResolve to auto-prefetch
router.beforeResolve((to) => {
  const matched = to.matched
  // Vite handles prefetch for dynamic imports automatically
})

Prefetch with Speculation Rules API

If you've adopted the Speculation Rules API for navigation (Chrome 121+), Vue Router can leverage browser prerender — the destination page is rendered ahead of time before the user clicks. Combined with route-level code splitting, this is the strongest combo for perceived performance.

7. Bundle Optimization — reducing what the browser receives

graph TD
    A[Source Code] --> B[Vite Build]
    B --> C[Tree Shaking]
    C --> D[Code Splitting]
    D --> E[Minification]
    E --> F[Compression]

    C -->|Removes dead code| G["~30-50% reduction"]
    D -->|Chunks by route| H["Load on demand"]
    E -->|Terser/esbuild| I["~20-30% reduction"]
    F -->|Brotli/Gzip| J["~70-80% reduction"]

    style A fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style B fill:#e94560,stroke:#fff,color:#fff
    style C fill:#2c3e50,stroke:#fff,color:#fff
    style D fill:#2c3e50,stroke:#fff,color:#fff
    style E fill:#2c3e50,stroke:#fff,color:#fff
    style F fill:#2c3e50,stroke:#fff,color:#fff
    style G fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
    style H fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
    style I fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
    style J fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50

Bundle optimization pipeline — each step significantly shrinks output size

7.1 Analyze bundles with rollup-plugin-visualizer

// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    vue(),
    visualizer({
      open: true,
      filename: 'stats.html',
      gzipSize: true,
      brotliSize: true,
    })
  ]
})

After build, open stats.html — a treemap showing which libraries take the most space. Common culprits: lodash (importing the whole library instead of specific functions), moment.js (replace with dayjs), icon libraries (importing everything instead of specific icons).

7.2 Import correctly — effective tree shaking

// Whole-library import — not tree-shakeable
import _ from 'lodash'
import * as Icons from '@heroicons/vue/24/solid'

// Import each function/component
import debounce from 'lodash-es/debounce'
import { ArrowLeftIcon, CheckIcon } from '@heroicons/vue/24/solid'

// UI library — whole-library import
import ElementPlus from 'element-plus'
app.use(ElementPlus) // Adds ~500KB to the bundle

// Component-level import (auto-import plugin)
// unplugin-vue-components + unplugin-auto-import
import { ElButton, ElInput, ElTable } from 'element-plus'

7.3 Configure chunking strategy

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Split vendor chunks — long-term cacheable
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          'ui-vendor': ['element-plus'],
          'chart-vendor': ['echarts'],
        }
      }
    },
    // Target modern browsers — smaller, faster
    target: 'es2022',
    // Chunk size warning
    chunkSizeWarningLimit: 500,
  }
})
Strategy Before After Impact
Tree shake lodash -> lodash-es 71KB 4KB -94%
moment.js -> dayjs 67KB 2.9KB -96%
Auto-import UI components ~500KB ~80KB -84%
Split vendor chunks 1 file, 1.2MB 5 files, cached Faster subsequent loads
Brotli compression 300KB gzip 240KB brotli -20%

8. Memory Management — preventing leaks

Memory leaks in Vue apps are usually silent — the app runs fine at first but slows down after a few minutes of use. Common causes:

8.1 Clean up side effects in onUnmounted

// Forgot cleanup — event listener lives forever
onMounted(() => {
  window.addEventListener('resize', handleResize)
  const timer = setInterval(fetchData, 5000)
})

// Cleanup done right
onMounted(() => {
  window.addEventListener('resize', handleResize)
  const timer = setInterval(fetchData, 5000)

  onUnmounted(() => {
    window.removeEventListener('resize', handleResize)
    clearInterval(timer)
  })
})

8.2 AbortController for async operations

// Cancel pending requests when the component unmounts
const fetchUserData = () => {
  const controller = new AbortController()

  onUnmounted(() => controller.abort())

  return fetch('/api/users', { signal: controller.signal })
    .then(res => res.json())
    .catch(err => {
      if (err.name !== 'AbortError') throw err
    })
}

8.3 Watch cleanup

// onCleanup inside watch — clean up prior effect before running next
watch(searchQuery, (query, _, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())

  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => results.value = data)
})

Detecting memory leaks

Use Chrome DevTools -> Memory -> Heap Snapshot. Snapshot before and after navigating away and back. Compare the two — objects that grew but never shrank are leaks. Pay special attention to Detached HTMLElement — DOM nodes removed from the tree but still held by references.

9. SSR and SSG — optimizing for First Contentful Paint

Client-side rendering (CSR) must wait for JavaScript to download + parse + execute before showing content. SSR and SSG solve this by rendering HTML on the server.

graph LR
    subgraph CSR["CSR (Client-Side)"]
        C1[Request] --> C2[Blank HTML]
        C2 --> C3[Download JS ~1-2s]
        C3 --> C4[Parse + Execute]
        C4 --> C5[Render content]
    end

    subgraph SSR["SSR (Server-Side)"]
        S1[Request] --> S2[Full HTML ready]
        S2 --> S3[Display immediately]
        S3 --> S4[Hydrate JS async]
    end

    style C1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style C2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style C3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style C4 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style C5 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style S1 fill:#e94560,stroke:#fff,color:#fff
    style S2 fill:#e94560,stroke:#fff,color:#fff
    style S3 fill:#e94560,stroke:#fff,color:#fff
    style S4 fill:#e94560,stroke:#fff,color:#fff

CSR vs SSR timelines — SSR shows content immediately

9.1 Nuxt 4 Hybrid Rendering

Nuxt 4 lets you configure rendering strategy per route:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // Home page — SSG (prerender at build time)
    '/': { prerender: true },

    // Blog posts — ISR (regenerate every 3600s)
    '/blog/**': { isr: 3600 },

    // Dashboard — CSR only (needs auth and realtime data)
    '/dashboard/**': { ssr: false },

    // API routes — cache 60s at the CDN edge
    '/api/**': { cache: { maxAge: 60 } },
  }
})

9.2 Selective Hydration

Not every component needs to be interactive immediately. Use LazyHydration to defer hydration for below-the-fold components:

<template>
  <!-- Hydrate immediately — above the fold -->
  <HeroBanner />

  <!-- Hydrate when visible in the viewport -->
  <LazyHydrationOnVisible>
    <FeatureShowcase />
  </LazyHydrationOnVisible>

  <!-- Hydrate when the user interacts (click, hover) -->
  <LazyHydrationOnInteraction :triggers="['click', 'mouseover']">
    <CommentSection />
  </LazyHydrationOnInteraction>

  <!-- Hydrate when idle — lowest priority -->
  <LazyHydrationWhenIdle>
    <Footer />
  </LazyHydrationWhenIdle>
</template>

10. Performance Profiling — measure before you tune

Optimizing without measurement is like treating a disease without a diagnosis. Vue DevTools provides a detailed profiler for component rendering.

10.1 Vue DevTools Performance Tab

Open Vue DevTools -> Performance -> click Record -> interact with the app -> Stop. You'll see:

  • Component render time — which component is the slowest?
  • Re-render count — which component re-renders unnecessarily?
  • Event timeline — the order of events that triggered re-renders

10.2 Lighthouse and Core Web Vitals

Metric Target Techniques
LCP (Largest Contentful Paint) < 2.5s SSR/SSG, preload critical resources, optimize images
INP (Interaction to Next Paint) < 200ms v-memo, virtual scrolling, debounce handlers, Vapor Mode
CLS (Cumulative Layout Shift) < 0.1 Skeleton loading, fixed dimensions, font preload
TTFB (Time to First Byte) < 800ms Edge caching, CDN, SSR streaming

10.3 Custom performance markers

// Measure render time for a specific component
import { onMounted, onBeforeMount } from 'vue'

const useRenderPerf = (componentName: string) => {
  onBeforeMount(() => {
    performance.mark(`${componentName}-start`)
  })

  onMounted(() => {
    performance.mark(`${componentName}-end`)
    performance.measure(
      `${componentName} render`,
      `${componentName}-start`,
      `${componentName}-end`
    )
    const measure = performance.getEntriesByName(`${componentName} render`)[0]
    if (measure.duration > 16) {
      console.warn(`${componentName} render took ${measure.duration.toFixed(1)}ms`)
    }
  })
}

11. Vue 3 production optimization checklist

A summary of every technique above, ordered by priority — start with changes that give the biggest impact and are easiest to apply:

Step 1 — Measure (Impact: required)
Run Lighthouse, open Vue DevTools Profiler. Identify real bottlenecks before tuning anything.
Step 2 — Code Splitting (Impact: high)
Route-level lazy loading for every page. Component-level lazy loading for heavy components (charts, editors, maps).
Step 3 — Bundle audit (Impact: high)
Run rollup-plugin-visualizer. Swap lodash -> lodash-es, moment -> dayjs. Auto-import UI components.
Step 4 — List optimization (Impact: high for data-heavy apps)
Virtual scrolling for lists > 100 items. shallowRef for large datasets. v-memo for complex list items.
Step 5 — SSR/SSG (Impact: high for SEO and FCP)
Consider Nuxt 4 hybrid rendering. SSG for static pages, SSR for dynamic pages, CSR for dashboards.
Step 6 — Fine-tuning (Impact: medium)
v-once for static content. KeepAlive with max prop. Clean up side effects. AbortController for fetch.
Step 7 — Vapor Mode (Impact: high, once stable)
When Vue 3.6 stabilizes — migrate leaf components to Vapor Mode. Start with components that re-render most.

12. Conclusion

Performance isn't a feature you tack on at the end of a project — it's a mindset you hold throughout development. Vue 3 ships a full toolkit from the compiler level (Vapor Mode, patch flags) to the runtime level (shallowRef, v-memo, KeepAlive) for building fast apps.

Most importantly: measure first, optimize second. Not every app needs virtual scrolling or Vapor Mode. Use the DevTools profiler and Lighthouse to find real bottlenecks, then apply the right technique. A well-placed shallowRef can be more effective than refactoring an entire component tree.

Keep an eye on Vue 3.6 stable

Vue 3.6 with Vapor Mode is in late beta. When it ships stable (expected Q2-Q3 2026), it will be the biggest single performance jump for the Vue ecosystem — on par with Svelte 5 and Solid.js without changing the mental model. Follow Vue.js Core on GitHub for updates.

References