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.
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.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.shallowRef for large arrays, markRaw for third-party instances (a chart, a map, an editor) stops Vue from proxying thousands of objects..client, or load third-party scripts through @nuxt/scripts with an idle/visible trigger.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.@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.experimental.emitRouteChunkError: 'automatic' (the default) reloads on a stale-chunk error after a deploy, and the app manifest detects the new version.web-vitals wired into a client plugin for real user monitoring, nuxt analyze for the bundle.Moving hydration cost off the critical path without losing server rendering:
<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:
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:
<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:
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)
})
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.
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.
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.<NuxtImg> plus preload and fetchpriority="high"; measure LCP before and after.web-vitals plugin and navigate around the app, watching LCP, INP and CLS in the console."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."
Server-side performance
Waterfalls, payload size, the caching layers Nitro gives you, and the rule that keeps caching from becoming a security incident.
The author's performance checklist
What a layer or module costs every consuming app, how to measure that cost, and how to keep it in CI so it cannot grow unnoticed.