DEV Community

Cover image for Vue & Nuxt Performance in 2026: The Complete Guide
Parsa Jiravand
Parsa Jiravand

Posted on

Vue & Nuxt Performance in 2026: The Complete Guide

shallowRef, error boundaries, CLS fixes, layout shift, and every trick that actually moves the needle.


Who This Is For

You're building with Vue 3 + Nuxt 4 and your app works — but Lighthouse isn't happy, users feel the jank, and you've run out of obvious things to fix. This guide goes beyond "add v-memo and call it a day." We'll cover reactivity tuning, rendering strategy, layout stability, Core Web Vitals, and Nuxt-specific patterns that most tutorials skip.


1. Reactivity: Use the Right Primitive

Vue's reactivity system is powerful but easy to misuse. Choosing the wrong primitive is the #1 hidden performance killer.

shallowRef — Your New Best Friend

ref() makes every nested property deeply reactive. For large objects or arrays you don't need to observe deeply, this is expensive.

import { shallowRef, triggerRef } from 'vue'

// Bad — deep reactivity on a 500-item list
const products = ref(hugeProductArray)

// Good — only the top-level reference is tracked
const products = shallowRef(hugeProductArray)

// To trigger an update after mutating internals:
products.value.push(newProduct)
triggerRef(products)
Enter fullscreen mode Exit fullscreen mode

When to use shallowRef:

  • Large arrays or lists (50+ items)
  • External data objects you don't mutate deeply
  • Chart/canvas data you replace wholesale

shallowReactive for Objects

import { shallowReactive } from 'vue'

// Only top-level keys are reactive
const state = shallowReactive({
  user: { name: 'Ali', preferences: { theme: 'dark' } },
  count: 0
})

// state.count triggers updates ✓
// state.user.name does NOT trigger updates — intentional
Enter fullscreen mode Exit fullscreen mode

markRaw — Opt Out of Reactivity Entirely

Libraries like chart instances, map objects, or Three.js scenes should never be reactive.

import { ref, markRaw } from 'vue'
import mapboxgl from 'mapbox-gl'

const mapInstance = ref(markRaw(new mapboxgl.Map({ ... })))
Enter fullscreen mode Exit fullscreen mode

Without markRaw, Vue walks the entire Mapbox object trying to make it reactive — causing massive overhead.

Computed vs Watchers

// Bad — watcher that derives state
const fullName = ref('')
watch([firstName, lastName], () => {
  fullName.value = `${firstName.value} ${lastName.value}`
})

// Good — computed is lazy and cached automatically
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
Enter fullscreen mode Exit fullscreen mode

Computed values are only re-evaluated when their dependencies change AND when they are actually read. Watchers run eagerly. Prefer computed for derived values, always.


2. Component Rendering: Be Strategic

v-memo — Skip Re-renders for List Items

<div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
  <!-- Only re-renders when item.id or item.selected changes -->
  <ExpensiveItemCard :item="item" />
</div>
Enter fullscreen mode Exit fullscreen mode

v-memo short-circuits the VDOM diff for that subtree. Use it on list items that have expensive child trees and rarely change.

defineAsyncComponent — Split by Component, Not Just Route

import { defineAsyncComponent } from 'vue'

// This entire subtree is lazy-loaded only when needed
const HeavyDataTable = defineAsyncComponent({
  loader: () => import('./HeavyDataTable.vue'),
  loadingComponent: SkeletonTable,
  errorComponent: ErrorState,
  delay: 200,       // show loading only after 200ms (avoids flash)
  timeout: 5000
})
Enter fullscreen mode Exit fullscreen mode

v-once for Truly Static Content

<!-- Rendered once, then treated as static HTML — zero reactivity overhead -->
<footer v-once>
  <LegalText />
  <SupportLinks />
</footer>
Enter fullscreen mode Exit fullscreen mode

Functional Components for Pure UI

// No instance, no reactivity overhead — pure transform of props to DOM
const Badge = (props: { label: string; variant: string }) => (
  <span class={`badge badge-${props.variant}`}>{props.label}</span>
)
Enter fullscreen mode Exit fullscreen mode

Use for small, stateless UI elements rendered many times.


3. Nuxt 4 Patterns That Actually Help

Error Boundaries with <NuxtErrorBoundary>

Stop letting one failing component crash your entire page.

<template>
  <div>
    <HeroSection />

    <NuxtErrorBoundary @error="logError">
      <template #default>
        <ComplexDashboardWidget />
      </template>
      <template #error="{ error, clearError }">
        <div class="error-card">
          <p>Widget failed to load.</p>
          <button @click="clearError">Retry</button>
        </div>
      </template>
    </NuxtErrorBoundary>

    <Footer />
  </div>
</template>

<script setup>
function logError(error) {
  console.error('[Widget error]', error)
  // send to your error tracking service
}
</script>
Enter fullscreen mode Exit fullscreen mode

Each <NuxtErrorBoundary> isolates failures. The rest of your page keeps working.

Rendering Modes Per Route

Nuxt 4 lets you set rendering strategy per route in nuxt.config.ts:

export default defineNuxtConfig({
  routeRules: {
    '/':              { prerender: true },       // static, fastest
    '/blog/**':       { isr: 3600 },             // ISR: revalidate hourly
    '/dashboard/**':  { ssr: false },            // SPA: client-only
    '/api/**':        { cors: true, cache: { maxAge: 60 } }
  }
})
Enter fullscreen mode Exit fullscreen mode

This is Nuxt's hybrid rendering. Use it aggressively — not every route needs SSR.

useLazyFetch and useLazyAsyncData

Don't block navigation for non-critical data:

// Blocks navigation until data is ready — use for critical above-fold content
const { data: hero } = await useFetch('/api/hero')

// Does NOT block — page loads immediately, data fills in
const { data: recommendations, pending } = useLazyFetch('/api/recommendations')
Enter fullscreen mode Exit fullscreen mode
<template>
  <RecommendationGrid v-if="!pending" :items="recommendations" />
  <RecommendationSkeleton v-else />
</template>
Enter fullscreen mode Exit fullscreen mode

useAsyncData Deduplication and Keys

// Both components share ONE request — not two
// Key must be unique and stable
const { data } = await useAsyncData('user-profile', () => $fetch('/api/me'))
Enter fullscreen mode Exit fullscreen mode

If two components on the same page call useAsyncData with the same key, Nuxt deduplicates the fetch automatically.

Payload Extraction to Avoid Hydration Waterfalls

// nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    payloadExtraction: true  // extracts server data to _payload.json
  }
})
Enter fullscreen mode Exit fullscreen mode

This prevents the client from re-fetching data that was already fetched during SSR, eliminating a full waterfall on first load.


4. Core Web Vitals: Fix the Metrics That Matter

CLS (Cumulative Layout Shift) — The Jank Killer

CLS measures how much your page shifts after load. Target: below 0.1.

The most common culprits:

Images without dimensions:

<!-- Bad — browser doesn't know how tall this is until it loads -->
<img src="/hero.jpg" alt="Hero" />

<!-- Good — browser reserves space immediately -->
<img src="/hero.jpg" alt="Hero" width="1200" height="600" />
Enter fullscreen mode Exit fullscreen mode

Or with Nuxt Image:

<NuxtImg
  src="/hero.jpg"
  width="1200"
  height="600"
  placeholder
  loading="lazy"
/>
Enter fullscreen mode Exit fullscreen mode

Dynamic content injecting above existing content:

<!-- Bad — banner loads late and pushes content down -->
<PromoBanner v-if="bannerLoaded" />
<MainContent />

<!-- Good — reserve space for the banner -->
<div style="min-height: 60px;">
  <PromoBanner v-if="bannerLoaded" />
</div>
<MainContent />
Enter fullscreen mode Exit fullscreen mode

Web fonts causing FOUT/FOIT:

// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      link: [
        {
          rel: 'preload',
          as: 'font',
          type: 'font/woff2',
          href: '/fonts/Inter.woff2',
          crossorigin: 'anonymous'
        }
      ]
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

And in your CSS:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/Inter.woff2') format('woff2');
  font-display: optional; /* prevents layout shift — no FOUT */
}
Enter fullscreen mode Exit fullscreen mode

font-display: optional is the most aggressive CLS fix. The browser uses the fallback font if the custom font isn't ready within a small window — no shift, ever.

LCP (Largest Contentful Paint) — Speed Up the Hero

LCP is the render time of your largest above-fold element. Target: under 2.5s.

Preload your hero image:

<Head>
  <Link
    rel="preload"
    as="image"
    href="/hero.webp"
    fetchpriority="high"
  />
</Head>
Enter fullscreen mode Exit fullscreen mode

Use fetchpriority on the hero <img>:

<NuxtImg
  src="/hero.webp"
  fetchpriority="high"
  loading="eager"
  width="1200"
  height="600"
/>
Enter fullscreen mode Exit fullscreen mode

Avoid lazy-loading above-fold images. loading="lazy" on your hero is a common mistake — it tells the browser to wait before fetching the most important image.

INP (Interaction to Next Paint) — Kill the Input Delay

INP replaced FID in 2024. It measures responsiveness across all interactions. Target: under 200ms.

Defer non-critical work:

import { nextTick } from 'vue'

async function handleHeavyClick() {
  // Update UI immediately
  isLoading.value = true
  await nextTick()

  // Then do the heavy work
  await processLargeDataset()
  isLoading.value = false
}
Enter fullscreen mode Exit fullscreen mode

Use useThrottleFn and useDebounceFn from VueUse:

import { useThrottleFn, useDebounceFn } from '@vueuse/core'

// Scroll handler — throttled to 60fps
const onScroll = useThrottleFn(() => {
  updateScrollPosition()
}, 16)

// Search input — debounced to avoid hammering the API
const onSearch = useDebounceFn((query: string) => {
  fetchResults(query)
}, 300)
Enter fullscreen mode Exit fullscreen mode

5. Bundle Size: Ship Less

Analyze Your Bundle

npx nuxi analyze
Enter fullscreen mode Exit fullscreen mode

This opens a visual treemap of your bundle. Do this before optimizing — never guess where the weight is.

Auto-imports Are Your Friend

Nuxt 4 auto-imports composables, components, and Vue APIs. Never import them manually:

// Don't do this in Nuxt
import { ref, computed } from 'vue'
import { useFetch } from '#app'

// Nuxt handles it — just use them
const count = ref(0)
const { data } = await useFetch('/api/data')
Enter fullscreen mode Exit fullscreen mode

Auto-imports are tree-shaken automatically — you only ship what you use.

Replace Heavy Libraries

Heavy library Lightweight alternative
moment.js (67kb) date-fns (tree-shakeable) or Temporal API
lodash (72kb) lodash-es (tree-shakeable) or native JS
axios (13kb) $fetch (built into Nuxt, 0kb extra)
animate.css (77kb) CSS custom properties + @starting-style

Dynamic Imports for Large Features

// Don't import chart libraries at the top level
// import { Chart } from 'chart.js' ← loads on every page

// Import only when the component mounts
const { Chart } = await import('chart.js/auto')
Enter fullscreen mode Exit fullscreen mode

6. Image Optimization with Nuxt Image

Install once, benefit everywhere:

npx nuxi module add image
Enter fullscreen mode Exit fullscreen mode
<NuxtImg
  src="/product.jpg"
  width="400"
  height="300"
  format="webp"
  quality="80"
  loading="lazy"
  placeholder
  sizes="sm:100vw md:50vw lg:400px"
/>
Enter fullscreen mode Exit fullscreen mode

What this gives you automatically:

  • WebP/AVIF conversion
  • Responsive srcset generation
  • Lazy loading with placeholder blur
  • CDN-aware serving (works with Cloudinary, Imgix, Vercel, etc.)

7. Caching Strategy

useFetch with Cache Control

const { data } = await useFetch('/api/products', {
  key: 'products-list',
  getCachedData: (key, nuxtApp) => {
    // Return cached data if it exists and is fresh
    return nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
  }
})
Enter fullscreen mode Exit fullscreen mode

Server-Side Cache Headers

// server/api/products.ts
export default defineEventHandler((event) => {
  setResponseHeaders(event, {
    'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400'
  })
  return getProducts()
})
Enter fullscreen mode Exit fullscreen mode

stale-while-revalidate is the key: the user always gets a fast cached response while the fresh data loads in the background.


8. The Performance Checklist

Reactivity

  • [ ] Large arrays/objects use shallowRef or shallowReactive
  • [ ] Third-party instances wrapped in markRaw
  • [ ] Derived values use computed, not watch + ref
  • [ ] Static content uses v-once
  • [ ] Expensive list items use v-memo

Nuxt

  • [ ] Route rules set per page type (prerender / ISR / SSR / SPA)
  • [ ] Non-critical fetches use useLazyFetch
  • [ ] Error boundaries wrap unstable widgets
  • [ ] payloadExtraction enabled
  • [ ] nuxi analyze run before shipping

Core Web Vitals

  • [ ] All images have explicit width + height
  • [ ] Hero image has fetchpriority="high" and loading="eager"
  • [ ] Fonts use font-display: optional or swap
  • [ ] Dynamic content reserves space with min-height
  • [ ] Heavy event handlers are debounced/throttled

Bundle

  • [ ] npx nuxi analyze shows no unexpected large dependencies
  • [ ] moment.js, full lodash, axios replaced with leaner alternatives
  • [ ] Chart and map libraries dynamically imported
  • [ ] Images served via Nuxt Image with WebP + responsive sizes

Final Thoughts

Vue and Nuxt give you every tool needed to build extremely fast applications — the patterns exist, they just need deliberate choices. The biggest wins in 2026 come from three places:

Reactivity disciplineshallowRef and markRaw are underused. Most apps pay a reactivity tax on objects that never needed deep observation.

Hybrid rendering — Not every page should SSR. Nuxt's routeRules is one config block that can cut your TTFB dramatically on static and cacheable content.

CLS and LCP are fixable — Almost always caused by missing image dimensions, late-loading fonts, or above-fold lazy loading. These are fast wins.

Profile first with nuxi analyze and Chrome DevTools Performance panel. Then fix what the data shows, not what feels slow.


Found this useful? Follow for more Vue/Nuxt deep dives.

Tags: #vue #nuxt #performance #webdev #javascript

Top comments (0)