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)
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
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({ ... })))
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}`)
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>
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
})
v-once for Truly Static Content
<!-- Rendered once, then treated as static HTML — zero reactivity overhead -->
<footer v-once>
<LegalText />
<SupportLinks />
</footer>
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>
)
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>
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 } }
}
})
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')
<template>
<RecommendationGrid v-if="!pending" :items="recommendations" />
<RecommendationSkeleton v-else />
</template>
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'))
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
}
})
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" />
Or with Nuxt Image:
<NuxtImg
src="/hero.jpg"
width="1200"
height="600"
placeholder
loading="lazy"
/>
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 />
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'
}
]
}
}
})
And in your CSS:
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter.woff2') format('woff2');
font-display: optional; /* prevents layout shift — no FOUT */
}
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>
Use fetchpriority on the hero <img>:
<NuxtImg
src="/hero.webp"
fetchpriority="high"
loading="eager"
width="1200"
height="600"
/>
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
}
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)
5. Bundle Size: Ship Less
Analyze Your Bundle
npx nuxi analyze
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')
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')
6. Image Optimization with Nuxt Image
Install once, benefit everywhere:
npx nuxi module add image
<NuxtImg
src="/product.jpg"
width="400"
height="300"
format="webp"
quality="80"
loading="lazy"
placeholder
sizes="sm:100vw md:50vw lg:400px"
/>
What this gives you automatically:
- WebP/AVIF conversion
- Responsive
srcsetgeneration - 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]
}
})
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()
})
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
shallowReforshallowReactive - [ ] Third-party instances wrapped in
markRaw - [ ] Derived values use
computed, notwatch+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
- [ ]
payloadExtractionenabled - [ ]
nuxi analyzerun before shipping
Core Web Vitals
- [ ] All images have explicit
width+height - [ ] Hero image has
fetchpriority="high"andloading="eager" - [ ] Fonts use
font-display: optionalorswap - [ ] Dynamic content reserves space with
min-height - [ ] Heavy event handlers are debounced/throttled
Bundle
- [ ]
npx nuxi analyzeshows no unexpected large dependencies - [ ]
moment.js, fulllodash,axiosreplaced 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 discipline — shallowRef 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)