Performance

Client-side performance

Code splitting, lazy hydration, images and fonts, third-party scripts, and the Core Web Vitals each of them moves.

Client performance in a Nuxt app is mostly about how much JavaScript has to be downloaded, parsed and hydrated before the page reacts to a click. Server rendering already gave you pixels early; the metric that suffers is INP, and the one that betrays sloppy rendering is CLS.

Know

  • Splitting is per page by default. What escapes it: components registered global: true, imports at the top level of a universal plugin, and anything the entry chunk touches. nuxt analyze shows you which.
  • Lazy* plus a hydration strategy is the highest-leverage client lever in Nuxt 4: hydrate-on-visible, hydrate-on-idle, hydrate-on-interaction, hydrate-on-media-query, hydrate-after, hydrate-when, hydrate-never. The markup still renders on the server, so SEO and LCP are unaffected while hydration CPU moves off the critical path.
  • Server components and islands (*.server.vue, <NuxtIsland>) remove a component's JavaScript from the client entirely. Right for heavy, static output: rendered markdown, syntax highlighting, tables, charts that do not need interaction.
  • Big data structures do not need deep reactivity. shallowRef for large arrays, markRaw for third-party instances (a chart, a map, an editor) stops Vue from proxying thousands of objects.
  • Plugins are the quietest weight. A universal plugin that imports an SDK at the top level puts that SDK in the entry chunk of every page. Import inside a function, use .client, or load third-party scripts through @nuxt/scripts with an idle/visible trigger.
  • Navigation: NuxtLink prefetches in-viewport internal links by default; turn it off for heavy targets (prefetch: false) and use prefetchOn to choose. useLazyAsyncData lets you navigate immediately and stream data in.
  • Images and fonts: @nuxt/image with sizes, a modern format and loading="lazy" for everything except the LCP image, which gets preload and fetchpriority="high". @nuxt/fonts self-hosts and sets fallback metric overrides, which is the standard CLS fix.
  • Deploy hygiene: experimental.emitRouteChunkError: 'automatic' (the default) reloads on a stale-chunk error after a deploy, and the app manifest detects the new version.
  • Measure: Lighthouse and CrUX for vitals, the Performance panel for long tasks and layout shifts, web-vitals wired into a client plugin for real user monitoring, nuxt analyze for the bundle.

How it works

Moving hydration cost off the critical path without losing server rendering:

app/pages/index.vue
<template>
  <!-- Above the fold: hydrate normally -->
  <TeamHero />

  <!-- Below the fold: server-rendered now, hydrated when it scrolls into view -->
  <LazyTeamTestimonials hydrate-on-visible />

  <!-- Only ever needed if the user opens it -->
  <LazyTeamSupportChat hydrate-on-interaction />

  <!-- Static, heavy, never interactive: no client JavaScript at all -->
  <TeamChangelog />   <!-- app/components/Team/Changelog.server.vue -->
</template>

A heavy library that should never be in the entry chunk:

app/plugins/analytics.client.ts
export default defineNuxtPlugin({
  name: 'team-analytics',
  parallel: true,
  setup() {
    // ✗ import Chart from 'chart.js'  at the top level puts it in every page's entry chunk
    // ✓ import it when the feature is actually used
    const load = async () => (await import('chart.js/auto')).default
    return { provide: { loadChart: load } }
  },
})

Taming a third-party instance so Vue does not proxy it:

app/components/Team/Chart.client.vue
<script setup lang="ts">
const el = useTemplateRef<HTMLCanvasElement>('canvas')
// markRaw keeps the instance out of the reactivity system; shallowRef avoids deep tracking of the data
const instance = shallowRef<import('chart.js').Chart>()
const rows = shallowRef<{ x: number, y: number }[]>([])

onMounted(async () => {
  const Chart = (await import('chart.js/auto')).default
  instance.value = markRaw(new Chart(el.value!, { type: 'line', data: { datasets: [{ data: rows.value }] } }))
})
onBeforeUnmount(() => instance.value?.destroy())
</script>

<template>
  <canvas ref="canvas" />
</template>

Real user monitoring in ten lines:

app/plugins/vitals.client.ts
import { onCLS, onINP, onLCP } from 'web-vitals'

export default defineNuxtPlugin(() => {
  const report = (metric: { name: string, value: number }) => {
    // send somewhere; console is fine while you are learning the numbers
    console.info(`[vitals] ${metric.name} = ${Math.round(metric.value)}`)
  }
  onLCP(report)
  onINP(report)
  onCLS(report)
})
Gotcha· Lazy hydration is not lazy rendering

hydrate-on-visible still renders the component on the server, so the HTML, its CSS and its data are all there. What is deferred is attaching Vue to it. If your goal is to stop shipping the markup or the data at all, you want a server component or a conditional fetch instead.

Gotcha· A `global: true` component in a layer

Global components are bundled into the entry chunk of every page of every consuming app, whether or not the page uses them. A layer should register components normally and let per-page splitting work; make global opt-in and say so in the README.

Exercise

Exercise
  • Import a charting library at the top of a universal plugin, run nuxt analyze, and note the entry chunk size. Move it into a dynamic import inside a Lazy component with hydrate-on-visible and compare the chunk sizes and Lighthouse TBT/INP.
  • Add a hero image with <NuxtImg> plus preload and fetchpriority="high"; measure LCP before and after.
  • Wire the web-vitals plugin and navigate around the app, watching LCP, INP and CLS in the console.

Be able to say

Be able to say· What are the biggest client-side levers in a Nuxt app?

"Ship less JavaScript and hydrate less of it. Code splitting is automatic per page, so the work is finding what escapes it: global components, top-level imports in universal plugins, third-party SDKs. Then lazy hydration strategies on below-the-fold components, and server components for heavy static output, which removes their JavaScript entirely. After that, images and fonts through the official modules for LCP and CLS, and deferring third-party scripts with @nuxt/scripts. I find the offenders with nuxt analyze and confirm with Lighthouse and the Performance panel, because bundle size alone does not tell you what INP will be."