In March 2024, Google replaced First Input Delay (FID) with Interaction to Next Paint (INP) as a Core Web Vital. The change was immediate and dramatic. Over 600,000 websites failed Core Web Vitals overnight. Sites that previously passed all metrics suddenly found themselves in the red.
This guide shows you how to optimize Core Web Vitals in Nuxt 3. You'll learn practical techniques to improve INP, LCP, and CLS. Each section includes working code examples you can copy and use. By the end, you'll know how to measure, monitor, and fix performance issues that hurt your search rankings.
Core Web Vitals directly impact your SEO. Google uses these metrics as ranking factors. Sites that pass all three thresholds see 8-15% visibility increases. Sites that fail lose traffic to faster competitors. The stakes are high, but Nuxt 3 gives you powerful tools to win.
What Are Core Web Vitals and Why They Matter in 2025
Core Web Vitals measure real user experience on your website. Google tracks three specific metrics that represent different aspects of performance.
Largest Contentful Paint (LCP) measures loading speed. It tracks how long it takes for the largest visible element to appear. This might be a hero image, heading, or text block. Good LCP happens in 2.5 seconds or less.
Interaction to Next Paint (INP) measures responsiveness. It tracks the delay between user actions and visual updates. When someone clicks a button, INP measures how long before they see feedback. Good INP is under 200 milliseconds.
Cumulative Layout Shift (CLS) measures visual stability. It tracks unexpected movement of page elements. If text suddenly jumps when an image loads, that's layout shift. Good CLS is under 0.1.
Here are the official benchmarks for each metric:
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP | ≤ 2.5s | 2.5s - 4.0s | > 4.0s |
| INP | ≤ 200ms | 200ms - 500ms | > 500ms |
| CLS | ≤ 0.1 | 0.1 - 0.25 | > 0.25 |
Only 47% of websites meet all three Core Web Vitals requirements in 2025. The other 53% fail at least one metric. This creates a massive opportunity for developers who know how to optimize.
Google uses Core Web Vitals as ranking signals. Sites with good scores rank higher than sites with poor scores. The difference shows up in search results, traffic numbers, and conversion rates. Fast sites win.
Understanding INP (Interaction to Next Paint)
INP replaced FID because FID only measured the first interaction. Users interact with pages multiple times. They scroll, click buttons, open menus, and fill forms. FID ignored everything after the first click.
INP measures all interactions throughout the page lifecycle. It finds the worst interaction and reports that value. This gives you a realistic picture of responsiveness problems.
Think about what happens when you click a button. The browser must run JavaScript event handlers. It must update the DOM. It must recalculate styles and paint pixels to the screen. All of this takes time. INP measures that entire chain.
A good INP score is under 200 milliseconds. Users perceive responses under 200ms as instant. Between 200ms and 500ms, they notice a slight delay. Above 500ms, the site feels sluggish and broken.
Most INP problems come from three sources:
- Heavy JavaScript execution blocks the main thread
- Large DOM updates force expensive recalculations
- Third-party scripts compete for processing time
The shift from FID to INP caught many developers off guard. FID was easy to pass. Just keep the initial page load light. INP is harder because it tests the entire user experience.
Sites with good FID scores often have terrible INP scores. A landing page might load quickly but hang when you click the navigation menu. INP catches these problems. FID missed them.
Core Web Vitals in Nuxt 3: Built-in Performance Features
Nuxt 3 gives you performance advantages out of the box. The framework was built with Core Web Vitals in mind. You get automatic optimizations that would take weeks to implement manually.
Automatic code splitting breaks your JavaScript into smaller chunks. Each page loads only the code it needs. This reduces initial bundle size and improves LCP. You don't configure anything. Nuxt handles it.
Server-side rendering (SSR) sends fully rendered HTML to the browser. Users see content before JavaScript downloads. This dramatically improves LCP compared to client-side apps. The largest contentful paint happens server-side.
Tree shaking removes unused code from production builds. If you import a utility library but only use one function, Nuxt removes the rest. Smaller bundles mean faster downloads and better INP.
Built-in lazy loading defers component rendering until needed. Components below the fold don't load immediately. This reduces initial JavaScript execution and improves both LCP and INP.
Nuxt Image module optimizes images automatically. It generates multiple sizes, converts to modern formats, and adds lazy loading. Images are the biggest LCP bottleneck. Nuxt Image solves this.
The framework's hybrid rendering lets you choose strategies per route. Static pages for marketing content. SSR for dynamic data. Client-side for authenticated experiences. Pick the right tool for each job.
Nuxt 3 uses Vite for development and building. Vite's fast bundling keeps your workflow smooth. But more importantly, Vite produces optimized production code. The output is lean and fast.
These features don't guarantee good Core Web Vitals scores. But they give you a strong foundation. You start ahead of developers using less optimized frameworks.
Setting Up Core Web Vitals Monitoring in Nuxt 3
You can't optimize what you don't measure. The first step is adding real user monitoring to your Nuxt 3 application. The @nuxtjs/web-vitals module makes this simple.
Install the module:
npm install @nuxtjs/web-vitals
Add it to your Nuxt configuration:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/web-vitals'],
webVitals: {
// Send metrics to Google Analytics
provider: 'ga',
// Debug mode for development
debug: process.env.NODE_ENV === 'development',
// Track all Core Web Vitals
metrics: ['LCP', 'FID', 'CLS', 'INP', 'FCP', 'TTFB']
}
})
The module automatically tracks metrics and sends them to your analytics provider. You see real user data, not lab scores. Real users have slow connections, old devices, and browser extensions. Lab tests don't capture this.
If you use Google Analytics 4, configure it with your measurement ID:
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'@nuxtjs/web-vitals',
'nuxt-gtag'
],
gtag: {
id: 'G-XXXXXXXXXX' // Your GA4 measurement ID
},
webVitals: {
provider: 'ga',
// Custom event names
eventPrefix: 'web-vitals',
// Send additional context
eventParams: {
page_path: true,
page_title: true,
user_agent: true
}
}
})
The module sends events to Google Analytics when metrics are measured. You can build custom reports to track Core Web Vitals over time.
For more control, use the web vitals library directly:
// plugins/web-vitals.client.ts
export default defineNuxtPlugin(() => {
if (process.client) {
import('web-vitals').then(({ onCLS, onINP, onLCP }) => {
onCLS(metric => {
console.log('CLS:', metric.value)
// Send to your analytics
})
onINP(metric => {
console.log('INP:', metric.value)
// Send to your analytics
})
onLCP(metric => {
console.log('LCP:', metric.value)
// Send to your analytics
})
})
}
})
This client-side plugin loads the web-vitals library only in the browser. It logs metrics to the console during development. In production, replace console.log with API calls to your analytics service.
Create a simple API endpoint to collect metrics:
// server/api/web-vitals.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// Validate the metric
if (!body.name || !body.value) {
throw createError({
statusCode: 400,
message: 'Invalid metric data'
})
}
// Store in your database or forward to analytics
console.log('Web Vital:', {
name: body.name,
value: body.value,
rating: body.rating,
path: body.path
})
return { success: true }
})
Update your plugin to send metrics to this endpoint:
// plugins/web-vitals.client.ts
export default defineNuxtPlugin(() => {
if (process.client) {
const sendMetric = (metric) => {
$fetch('/api/web-vitals', {
method: 'POST',
body: {
name: metric.name,
value: metric.value,
rating: metric.rating,
path: window.location.pathname
}
}).catch(err => {
console.error('Failed to send metric:', err)
})
}
import('web-vitals').then(({ onCLS, onINP, onLCP }) => {
onCLS(sendMetric)
onINP(sendMetric)
onLCP(sendMetric)
})
}
})
Now you have real user monitoring running. You collect actual performance data from visitors. This data guides your optimization work.
Optimizing LCP (Largest Contentful Paint) in Nuxt 3
LCP measures how fast your main content appears. Most LCP problems come from slow image loading. Nuxt Image solves this with automatic optimization.
Install Nuxt Image:
npm install @nuxt/image
Add it to your configuration:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/image'],
image: {
// Use built-in image optimization
provider: 'ipx',
// Define common image sizes
screens: {
xs: 320,
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
xxl: 1536
},
// Default image quality
quality: 80,
// Supported formats
format: ['webp', 'jpg', 'png']
}
})
Replace standard img tags with NuxtImg:
<template>
<NuxtImg
src="/hero-image.jpg"
alt="Hero banner showing product features"
width="1200"
height="630"
loading="lazy"
format="webp"
quality="80"
/>
</template>
The component automatically generates optimized images. It serves WebP to browsers that support it. It falls back to JPEG for older browsers. It creates responsive sizes using srcset.
For hero images that are your LCP element, don't use lazy loading:
<template>
<NuxtImg
src="/hero-image.jpg"
alt="Hero banner"
width="1200"
height="630"
loading="eager"
fetchpriority="high"
format="webp"
preload
/>
</template>
The fetchpriority="high" attribute tells the browser to prioritize this image. The preload prop adds a link preload tag to the document head. The browser starts downloading immediately.
Font loading also impacts LCP. Use the useHead composable to optimize font loading:
// app.vue or your layout
useHead({
link: [
{
rel: 'preconnect',
href: 'https://fonts.googleapis.com'
},
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossorigin: ''
},
{
rel: 'preload',
as: 'style',
href: 'https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap'
}
]
})
Better yet, self-host your fonts to eliminate the external connection:
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
link: [
{
rel: 'preload',
href: '/fonts/poppins-400.woff2',
as: 'font',
type: 'font/woff2',
crossorigin: ''
},
{
rel: 'preload',
href: '/fonts/poppins-600.woff2',
as: 'font',
type: 'font/woff2',
crossorigin: ''
}
]
}
}
})
Define font-face rules in your CSS:
/* assets/style.css */
@font-face {
font-family: 'Poppins';
font-weight: 400;
font-style: normal;
font-display: swap;
src: url('/fonts/poppins-400.woff2') format('woff2');
}
@font-face {
font-family: 'Poppins';
font-weight: 600;
font-style: normal;
font-display: swap;
src: url('/fonts/poppins-600.woff2') format('woff2');
}
The font-display: swap property shows fallback text immediately. When the custom font loads, it swaps in. This prevents invisible text and improves LCP.
Preload critical CSS to eliminate render-blocking resources:
// nuxt.config.ts
export default defineNuxtConfig({
experimental: {
inlineSSRStyles: false
},
hooks: {
'build:manifest': (manifest) => {
// Extract critical CSS
for (const key in manifest) {
const file = manifest[key]
if (file.isEntry && file.css) {
file.css.forEach(cssFile => {
// Mark CSS for preload
manifest[cssFile].prefetch = false
manifest[cssFile].preload = true
})
}
}
}
}
})
Use critical CSS inlining for above-the-fold content:
<!-- app.vue -->
<template>
<div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
<script setup>
useHead({
style: [
{
innerHTML: `
/* Critical CSS for above-the-fold content */
body { margin: 0; font-family: Poppins, sans-serif; }
.hero { min-height: 100vh; display: flex; align-items: center; }
`,
type: 'text/css'
}
]
})
</script>
Inline critical CSS in the document head. Load the full stylesheet asynchronously. This renders above-the-fold content immediately.
Defer non-critical resources using lazy loading:
<template>
<div>
<!-- Above the fold content -->
<section class="hero">
<h1>Welcome</h1>
</section>
<!-- Below the fold - lazy load -->
<LazyFeaturesSection />
<LazyTestimonialsSection />
<LazyFooter />
</div>
</template>
Prefix component names with "Lazy" and Nuxt automatically code splits them. They only load when they enter the viewport. This reduces initial JavaScript and improves LCP.
Improving INP (Interaction to Next Paint) Performance
INP measures responsiveness. When users click, tap, or type, they expect instant feedback. Long delays frustrate users and hurt your Core Web Vitals score.
The main thread handles user interactions. If JavaScript blocks the main thread, interactions queue up. The browser can't respond until the thread is free.
Minimize your JavaScript bundle size. Smaller bundles parse and execute faster. This keeps the main thread available for interactions.
Analyze your bundle with Nuxt DevTools:
npm run build
npx nuxi analyze
This generates a visual breakdown of your bundle. You see which packages consume the most space. Target the biggest chunks for optimization.
Replace heavy libraries with lighter alternatives:
// Bad - moment.js is 70KB minified
import moment from 'moment'
const formatted = moment().format('YYYY-MM-DD')
// Good - date-fns is modular, only import what you need
import { format } from 'date-fns'
const formatted = format(new Date(), 'yyyy-MM-dd')
Use dynamic imports for code splitting. Load features only when users need them:
<template>
<div>
<button @click="openModal">Open Chart</button>
<component :is="ChartModal" v-if="showModal" />
</div>
</template>
<script setup>
const showModal = ref(false)
const ChartModal = ref(null)
const openModal = async () => {
// Load the modal component only when clicked
if (!ChartModal.value) {
const module = await import('~/components/ChartModal.vue')
ChartModal.value = module.default
}
showModal.value = true
}
</script>
The chart library only downloads when users click the button. This reduces initial bundle size and improves INP.
Defer hydration for heavy components. Nuxt lazy hydration prevents components from hydrating immediately:
<template>
<div>
<!-- Hydrate when visible -->
<LazyHydrationWrapper :when-visible="true">
<HeavyDataTable :data="tableData" />
</LazyHydrationWrapper>
<!-- Hydrate when idle -->
<LazyHydrationWrapper :when-idle="true">
<CommentsSection />
</LazyHydrationWrapper>
</div>
</template>
Create the wrapper component:
<!-- components/LazyHydrationWrapper.vue -->
<template>
<div ref="target">
<slot v-if="hydrated" />
<div v-else v-html="placeholder" />
</div>
</template>
<script setup>
const props = defineProps({
whenVisible: Boolean,
whenIdle: Boolean
})
const hydrated = ref(false)
const target = ref(null)
onMounted(() => {
if (props.whenVisible) {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
hydrated.value = true
observer.disconnect()
}
})
observer.observe(target.value)
} else if (props.whenIdle) {
requestIdleCallback(() => {
hydrated.value = true
})
}
})
</script>
This defers interactive features until the browser is idle or the element is visible. The main thread stays free for critical interactions.
Debounce expensive operations. If users type in a search box, don't run searches on every keystroke:
<template>
<input
v-model="searchQuery"
@input="handleSearch"
placeholder="Search products..."
/>
</template>
<script setup>
const searchQuery = ref('')
const searchResults = ref([])
// Debounce the search to avoid excessive API calls
const handleSearch = useDebounceFn(async () => {
if (searchQuery.value.length < 3) return
searchResults.value = await $fetch('/api/search', {
params: { q: searchQuery.value }
})
}, 300)
</script>
The useDebounceFn composable delays execution until users stop typing. This reduces unnecessary work and keeps INP low.
Optimize event handlers. Long-running handlers block the main thread:
<script setup>
// Bad - synchronous processing blocks the thread
const handleClick = () => {
const results = processHugeDataset(data)
displayResults(results)
}
// Good - break work into chunks
const handleClick = async () => {
// Yield to the browser between chunks
const chunks = chunkArray(data, 100)
const results = []
for (const chunk of chunks) {
results.push(...processChunk(chunk))
// Let the browser handle other tasks
await new Promise(resolve => setTimeout(resolve, 0))
}
displayResults(results)
}
</script>
This yields control back to the browser between processing chunks. User interactions can run during these breaks.
Use Web Workers for heavy computations:
// composables/useWebWorker.ts
export const useWebWorker = () => {
const processData = (data) => {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL('~/workers/data-processor.ts', import.meta.url),
{ type: 'module' }
)
worker.onmessage = (e) => {
resolve(e.data)
worker.terminate()
}
worker.onerror = (error) => {
reject(error)
worker.terminate()
}
worker.postMessage(data)
})
}
return { processData }
}
Create the worker file:
// workers/data-processor.ts
self.onmessage = (e) => {
const data = e.data
// Perform heavy computation
const result = performExpensiveCalculation(data)
// Send result back to main thread
self.postMessage(result)
}
function performExpensiveCalculation(data) {
// Your computation logic
return data.map(item => {
// Complex processing
return transformedItem
})
}
Use it in your component:
<script setup>
const { processData } = useWebWorker()
const handleProcess = async () => {
const result = await processData(largeDataset)
displayResults(result)
}
</script>
Web Workers run in a separate thread. They don't block the main thread. This keeps INP low even during heavy processing.
Reducing CLS (Cumulative Layout Shift)
CLS measures visual stability. Content shouldn't jump around while loading. Layout shifts frustrate users and harm your Core Web Vitals score.
Always specify dimensions for images and videos:
<template>
<!-- Bad - no dimensions, causes layout shift -->
<img src="/product.jpg" alt="Product photo" />
<!-- Good - reserved space prevents shift -->
<NuxtImg
src="/product.jpg"
alt="Product photo"
width="800"
height="600"
/>
</template>
The browser reserves the correct space before the image loads. No shift when the image appears.
For responsive images, use aspect ratio:
<template>
<div class="image-container">
<NuxtImg
src="/hero.jpg"
alt="Hero image"
class="responsive-image"
/>
</div>
</template>
<style scoped>
.image-container {
position: relative;
width: 100%;
/* 16:9 aspect ratio */
padding-bottom: 56.25%;
}
.responsive-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
The padding-bottom trick reserves space based on aspect ratio. The image fills the container without causing shift.
Load fonts without layout shift:
/* assets/style.css */
@font-face {
font-family: 'Poppins';
font-weight: 400;
font-style: normal;
font-display: swap;
src: url('/fonts/poppins-400.woff2') format('woff2');
/* Add size-adjust to match fallback metrics */
size-adjust: 100%;
}
body {
font-family: 'Poppins', Arial, sans-serif;
}
The font-display: swap shows fallback text immediately. But Arial and Poppins have different metrics. When Poppins loads, text reflows and causes shift.
Use font fallback matching:
@font-face {
font-family: 'Poppins Fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
body {
font-family: 'Poppins', 'Poppins Fallback', Arial, sans-serif;
}
The size-adjust and metric overrides make Arial match Poppins dimensions. No layout shift when the web font loads.
Use skeleton screens for loading states:
<template>
<div>
<!-- Show skeleton while loading -->
<div v-if="loading" class="skeleton">
<div class="skeleton-header"></div>
<div class="skeleton-text"></div>
<div class="skeleton-text"></div>
</div>
<!-- Show real content when loaded -->
<div v-else class="content">
<h2>{{ article.title }}</h2>
<p>{{ article.content }}</p>
</div>
</div>
</template>
<style scoped>
.skeleton {
/* Match the dimensions of real content */
padding: 20px;
}
.skeleton-header {
width: 60%;
height: 32px;
background: #e0e0e0;
border-radius: 4px;
margin-bottom: 16px;
}
.skeleton-text {
width: 100%;
height: 16px;
background: #e0e0e0;
border-radius: 4px;
margin-bottom: 8px;
}
.content {
/* Same padding as skeleton */
padding: 20px;
}
</style>
The skeleton reserves the exact space real content needs. When content loads, nothing shifts.
Avoid inserting content above existing content:
<template>
<div>
<!-- Bad - banner pushes content down -->
<div v-if="showBanner" class="banner">
Special offer!
</div>
<main>
<!-- Content shifts when banner appears -->
<h1>Welcome</h1>
</main>
</div>
</template>
Fix by reserving space:
<template>
<div>
<!-- Reserve space for banner -->
<div class="banner-container">
<div v-if="showBanner" class="banner">
Special offer!
</div>
</div>
<main>
<!-- Content stays in place -->
<h1>Welcome</h1>
</main>
</div>
</template>
<style scoped>
.banner-container {
/* Reserve space even when empty */
min-height: 60px;
}
.banner {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: #1172f0;
color: white;
}
</style>
Use CSS containment for isolated components:
.article-card {
/* Contain layout changes to this element */
contain: layout;
/* Or contain everything */
contain: strict;
}
.sidebar {
/* Contain just layout and style */
contain: layout style;
}
CSS containment tells the browser that changes inside the element won't affect outside layout. This prevents shifts from propagating.
Preload key resources to avoid delayed rendering:
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
link: [
{
rel: 'preload',
href: '/logo.svg',
as: 'image'
}
]
}
}
})
The logo loads earlier. It's ready when the page renders. No shift when it appears.
Advanced Performance Optimization Techniques
Beyond the basics, you can use advanced techniques to squeeze more performance from your Nuxt 3 application.
Optimize resource hints with useHead:
// composables/useOptimizedHead.ts
export const useOptimizedHead = () => {
useHead({
link: [
// Preconnect to critical third-party domains
{
rel: 'preconnect',
href: 'https://analytics.google.com'
},
{
rel: 'dns-prefetch',
href: 'https://fonts.googleapis.com'
}
]
})
}
Use this composable in your pages:
<script setup>
useOptimizedHead()
</script>
Preconnect establishes early connections to servers you'll definitely use. DNS-prefetch only resolves DNS for servers you might use. This saves time on network requests.
Implement service workers for offline support and caching:
Install the Vite PWA plugin:
npm install @vite-pwa/nuxt
Configure it:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@vite-pwa/nuxt'],
pwa: {
registerType: 'autoUpdate',
manifest: {
name: 'Your App Name',
short_name: 'App',
theme_color: '#1172f0',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/icon-512.png',
sizes: '512x512',
type: 'image/png'
}
]
},
workbox: {
// Cache navigation requests
navigateFallback: '/',
// Cache static resources
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
}
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/i,
handler: 'CacheFirst',
options: {
cacheName: 'images-cache',
expiration: {
maxEntries: 60,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
}
}
}
]
}
}
})
Service workers cache resources. Repeat visitors load from cache. This dramatically improves LCP for returning users.
Analyze your bundle size:
# Build and analyze
npm run build
npx nuxi analyze
The analysis shows:
- Bundle composition by module
- Duplicate dependencies
- Large packages you might replace
- Code splitting opportunities
Look for packages that appear multiple times. Nuxt might include the same library in different chunks. Configure shared chunks:
// nuxt.config.ts
export default defineNuxtConfig({
vite: {
build: {
rollupOptions: {
output: {
manualChunks: {
// Group common dependencies
vendor: ['vue', 'vue-router'],
utils: ['date-fns', 'lodash-es']
}
}
}
}
}
})
This creates shared chunks for common dependencies. They load once and cache for all pages.
Use resource hints for critical paths:
<script setup>
// Prefetch route components
const router = useRouter()
onMounted(() => {
// Prefetch the next likely route
router.prefetch('/products')
})
</script>
Nuxt automatically prefetches links in the viewport. You can manually prefetch routes users are likely to visit. The route is ready when they click.
Implement streaming SSR for faster time to first byte:
// nuxt.config.ts
export default defineNuxtConfig({
experimental: {
renderJsonPayloads: false
},
nitro: {
experimental: {
streaming: true
}
}
})
Streaming SSR sends HTML as it's generated. The browser can start parsing before the full page is ready. This improves TTFB and LCP.
Testing and Measuring Core Web Vitals
You need multiple tools to get the full picture. Lab tools test controlled conditions. Field tools measure real users.
Google PageSpeed Insights combines both:
- Visit https://pagespeed.web.dev/
- Enter your URL
- Click "Analyze"
You get lab data from Lighthouse and field data from Chrome User Experience Report. The field data shows how real users experience your site over the past 28 days.
Look at the "Field Data" section first. This is real user experience. If field data shows good scores, you're doing well. If lab scores are good but field scores are poor, your test environment doesn't match real users.
Chrome DevTools gives you detailed debugging:
- Open DevTools (F12)
- Go to the Performance panel
- Click the record button
- Interact with your page
- Stop recording
The timeline shows exactly what happens. You see JavaScript execution, layout calculations, and paint operations. Find the longest tasks. Those are your INP problems.
Use the Performance Insights panel for Core Web Vitals:
- Open DevTools
- Press Ctrl+Shift+P (Cmd+Shift+P on Mac)
- Type "Show Performance Insights"
- Record and interact with your page
This panel highlights Core Web Vitals issues directly. It shows which interactions are slow and why.
WebPageTest gives advanced testing options:
- Visit https://www.webpagetest.org/
- Enter your URL
- Choose a test location and device
- Click "Start Test"
You can test from different locations worldwide. You can throttle connection speed. You can test on mobile devices. This shows how your site performs for users in different conditions.
The filmstrip view shows loading visually. You see exactly when LCP happens. The waterfall shows every resource and when it loads.
Lighthouse CI automates testing in your deployment pipeline:
Install Lighthouse CI:
npm install --save-dev @lhci/cli
Create a configuration file:
// lighthouserc.js
module.exports = {
ci: {
collect: {
startServerCommand: 'npm run preview',
url: ['http://localhost:3000'],
numberOfRuns: 3
},
assert: {
preset: 'lighthouse:recommended',
assertions: {
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['error', { maxNumericValue: 300 }]
}
},
upload: {
target: 'temporary-public-storage'
}
}
}
Add a script to package.json:
{
"scripts": {
"lighthouse": "lhci autorun"
}
}
Run it locally:
npm run build
npm run lighthouse
Lighthouse runs multiple times and averages the results. It fails if any assertion doesn't pass. This catches performance regressions before they reach production.
Integrate with GitHub Actions:
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run Lighthouse CI
run: npm run lighthouse
Every pull request gets tested. You see Core Web Vitals scores before merging. Regressions get caught early.
Real User Monitoring with web-vitals:
The web-vitals library we set up earlier collects real user data. You can track this in your own analytics or use a service.
Popular RUM services:
- Google Analytics 4 (free)
- Cloudflare Web Analytics (free)
- SpeedCurve (paid)
- Calibre (paid)
These services collect metrics from every visitor. You see percentile distributions. You can segment by device, location, or connection type. You see which pages need optimization.
Set up custom reporting in Google Analytics:
- Go to Google Analytics
- Click "Explore"
- Create a new exploration
- Add dimensions: Page path, Device category
- Add metrics: Average web-vitals-LCP, Average web-vitals-INP, Average web-vitals-CLS
- Filter to show only poor scores
This report shows which pages and devices have the worst Core Web Vitals. Focus your optimization there.
Common Core Web Vitals Issues in Nuxt 3 (and How to Fix Them)
Here are the most common problems and their solutions:
| Issue | Impact | Solution |
|---|---|---|
| Large third-party scripts | Increases INP, delays LCP | Load scripts async, use facade pattern for embeds |
| Unoptimized images | Poor LCP | Use Nuxt Image with WebP and proper sizing |
| Heavy client-side hydration | Poor INP | Implement lazy hydration, reduce JavaScript |
| No font preloading | Poor LCP, causes CLS | Preload fonts, use font-display: swap |
| Missing image dimensions | Poor CLS | Always specify width and height |
| Render-blocking CSS | Poor LCP | Inline critical CSS, defer non-critical |
| Large JavaScript bundles | Poor INP | Code splitting, tree shaking, remove unused libraries |
| No resource hints | Poor LCP | Use preconnect for critical origins |
| Slow server response | Poor LCP, poor TTFB | Enable caching, use CDN, optimize database queries |
| Layout shift from ads | Poor CLS | Reserve space for ad slots |
Fixing third-party scripts:
Many sites load analytics, chat widgets, and social media embeds. These scripts are often large and slow.
Use the facade pattern for YouTube embeds:
<template>
<div class="youtube-embed">
<div
v-if="!loaded"
class="youtube-facade"
@click="loadVideo"
:style="{ backgroundImage: `url(https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg)` }"
>
<button class="play-button">▶</button>
</div>
<iframe
v-else
:src="`https://www.youtube.com/embed/${videoId}?autoplay=1`"
frameborder="0"
allow="autoplay; encrypted-media"
></iframe>
</div>
</template>
<script setup>
const props = defineProps(['videoId'])
const loaded = ref(false)
const loadVideo = () => {
loaded.value = true
}
</script>
<style scoped>
.youtube-facade {
cursor: pointer;
position: relative;
background-size: cover;
background-position: center;
width: 100%;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
}
.play-button {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: red;
color: white;
border: none;
font-size: 48px;
padding: 20px 30px;
border-radius: 12px;
cursor: pointer;
}
</style>
The facade shows a thumbnail. The heavy iframe only loads when users click. This saves hundreds of kilobytes and eliminates third-party JavaScript during initial load.
Reducing hydration overhead:
Large client-side hydration hurts INP. Nuxt must process your entire component tree to make it interactive.
Use server-only components for static content:
<!-- components/ServerOnlyContent.vue -->
<template>
<div class="server-only">
<slot />
</div>
</template>
<script setup>
// This component won't be included in client bundle
defineOptions({
__ssrOnly: true
})
</script>
Use it for static sections:
<template>
<div>
<ServerOnlyContent>
<article>
<!-- Static content that doesn't need interactivity -->
<h1>About Us</h1>
<p>Company history and information...</p>
</article>
</ServerOnlyContent>
<!-- Only interactive features hydrate -->
<ContactForm />
</div>
</template>
The static content renders on the server but doesn't hydrate on the client. This reduces the JavaScript bundle and improves INP.
Optimizing fonts:
Missing font optimization causes both LCP and CLS issues.
Create a font loading utility:
// composables/useFontLoading.ts
export const useFontLoading = () => {
const fontsLoaded = ref(false)
onMounted(async () => {
if ('fonts' in document) {
await document.fonts.ready
fontsLoaded.value = true
}
})
return { fontsLoaded }
}
Use it to prevent CLS:
<script setup>
const { fontsLoaded } = useFontLoading()
</script>
<template>
<h1 :class="{ 'fonts-ready': fontsLoaded }">
Welcome to Our Site
</h1>
</template>
<style scoped>
h1 {
/* Use system font until web font loads */
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
transition: font-family 0s;
}
h1.fonts-ready {
font-family: 'Poppins', sans-serif;
}
</style>
This prevents the jarring font swap that causes CLS.
Real-World Results
After implementing these optimizations, you can expect measurable improvements. The timeline varies based on your starting point and the extent of changes.
Week 1-2: Lab scores improve immediately. Run Lighthouse after each change. You'll see better LCP, INP, and CLS scores. These are controlled test results.
Week 3-4: Field data starts improving. Google PageSpeed Insights shows the rolling 28-day average from Chrome User Experience Report. Your improvements gradually appear in field data.
Week 4-8: SEO impact becomes visible. Google's algorithm considers Core Web Vitals as a ranking factor. Sites that pass all three thresholds gain an advantage. You might see increased impressions and clicks in Google Search Console.
Metrics to track:
Track these in Google Analytics or your RUM solution:
- 75th percentile LCP: Should be under 2.5 seconds
- 75th percentile INP: Should be under 200 milliseconds
- 75th percentile CLS: Should be under 0.1
- Percentage of page loads with good scores: Aim for 75% or higher
- Bounce rate: Should decrease as performance improves
- Average session duration: Should increase
Segment by device type. Mobile performance is usually worse than desktop. Focus optimization efforts where they matter most.
Expected improvements by metric:
LCP: Image optimization alone can reduce LCP by 40-60%. Font optimization adds another 10-20%. Combined with server improvements, you can cut LCP in half.
INP: Code splitting and lazy hydration can reduce INP by 50-70%. Removing heavy third-party scripts has the biggest single impact.
CLS: Proper image dimensions and font loading eliminate most CLS issues. Going from 0.25 to under 0.1 is achievable in one optimization session.
Real sites have seen:
- 8-15% increase in organic search visibility
- 10-30% reduction in bounce rate
- 15-25% increase in pages per session
- 5-10% improvement in conversion rate
These benefits compound. Better performance creates better user experience. Better user experience drives better business metrics.
Conclusion
Optimizing Core Web Vitals in Nuxt 3 requires attention to three key areas. Start with LCP by optimizing images, fonts, and critical resources. Move to INP by reducing JavaScript, implementing code splitting, and deferring hydration. Finish with CLS by setting image dimensions, optimizing font loading, and using skeleton screens.
Nuxt 3 gives you powerful built-in tools. Server-side rendering improves LCP. Automatic code splitting helps INP. The Nuxt Image module solves most image optimization challenges. You start ahead of developers using less optimized frameworks.
Measure before you optimize. Set up real user monitoring with the web-vitals library. Track field data in Google PageSpeed Insights. Run Lighthouse CI in your deployment pipeline. These tools catch regressions and prove improvements.
Start with LCP. It has the biggest SEO impact and the most obvious solutions. Then tackle INP, which requires more careful JavaScript optimization. Finally address CLS, which is often the easiest to fix.
Core Web Vitals aren't a one-time project. New features add code and resources. Third-party scripts change. User behavior shifts. Set up continuous monitoring. Make performance a regular part of code review. Test on real devices with real network conditions.
The effort pays off. Sites that pass all three Core Web Vitals thresholds rank higher, convert better, and retain users longer. Your Nuxt 3 application can deliver excellent performance. You now have the knowledge and tools to make it happen.
FAQ
What is a good Core Web Vitals score for Nuxt 3 applications?
A good score means passing all three thresholds: LCP under 2.5 seconds, INP under 200 milliseconds, and CLS under 0.1. These benchmarks apply to the 75th percentile of page loads. At least 75% of your visitors should experience good scores. Nuxt 3 applications can consistently achieve these targets with proper optimization. Many well-optimized Nuxt sites score in the 90-100 range on Lighthouse.
How long does it take to see SEO improvements from Core Web Vitals optimization?
Lab scores improve immediately after deploying changes. Field data in Google PageSpeed Insights updates over 28 days as it collects real user metrics. SEO impact typically appears 4-8 weeks after field data shows consistent improvements. Google's algorithm considers the 28-day rolling average. You need sustained good scores before ranking benefits appear. Track your progress in Google Search Console to see impressions and click-through rates improve.
Does Nuxt 3 automatically optimize Core Web Vitals?
Nuxt 3 provides excellent foundations but doesn't automatically guarantee good scores. The framework includes automatic code splitting, tree shaking, and server-side rendering. These features help performance. You still need to optimize images, manage third-party scripts, set proper dimensions, and minimize JavaScript. Think of Nuxt 3 as giving you the right tools. You must use them correctly.
What's the difference between INP and FID?
FID (First Input Delay) measured only the first user interaction on a page. It missed slow interactions that happened later. INP (Interaction to Next Paint) measures all interactions throughout the page lifecycle. It reports the worst interaction delay. INP gives a more complete picture of responsiveness. Google replaced FID with INP in March 2024 because INP better represents actual user experience.
Should I prioritize LCP or INP optimization first?
Start with LCP. It typically has the biggest SEO impact and the most straightforward solutions. Image optimization, font loading, and critical CSS can dramatically improve LCP in days. INP optimization requires deeper JavaScript analysis and code restructuring. CLS is often the easiest to fix but has the smallest direct SEO impact. Tackle them in this order: LCP, then INP, then CLS. You'll see faster results and build momentum.
Can I optimize Core Web Vitals without affecting my site's functionality?
Yes. Most optimizations improve performance without changing features. Image optimization, code splitting, and lazy loading are invisible to users. They make your site faster without removing functionality. Some techniques like lazy hydration require careful implementation to avoid breaking interactive features. Test thoroughly after making changes. Use feature flags to roll out optimizations gradually. The goal is better performance with identical functionality.

