Core Web Vitals in Nuxt: Mastering LCP, FID, and CLS
Complete guide to measuring and optimizing Core Web Vitals in Nuxt applications. Learn how to achieve green scores on Google PageSpeed Insights with real strategies.
Understanding Core Web Vitals
Google's Core Web Vitals measure three aspects of user experience:
- LCP (Largest Contentful Paint) — How fast does content appear?
- Target: < 2.5s
- Measures: When largest element (image, heading, video) becomes visible
- FID (First Input Delay) — How responsive is the page?
- Target: < 100ms
- Measures: Delay from user input to browser processing
- Being replaced by INP in 2024
- CLS (Cumulative Layout Shift) — How stable is the layout?
- Target: < 0.1
- Measures: Unexpected visual shifts during page load
Measuring Web Vitals
1. Google PageSpeed Insights
https://pagespeed.web.dev/?url=yoursite.com
Shows real-world data from actual users. This is what matters most.
2. Local Testing
Install web-vitals library:
npm install web-vitals
Track metrics in Nuxt:
// plugins/web-vitals.ts
import { getCLS, getFCP, getFID, getLCP, getTTFB } from 'web-vitals'
export default defineNuxtPlugin(() => {
getCLS(console.log)
getFCP(console.log)
getFID(console.log)
getLCP(console.log)
getTTFB(console.log)
})
Send to analytics:
import { getCLS, getLCP, getFID } from 'web-vitals'
const vitals = {
cls: null,
lcp: null,
fid: null
}
getCLS((metric) => {
vitals.cls = metric.value
sendToAnalytics('CLS', metric.value)
})
getLCP((metric) => {
vitals.lcp = metric.value
sendToAnalytics('LCP', metric.value)
})
getFID((metric) => {
vitals.fid = metric.value
sendToAnalytics('FID', metric.value)
})
Optimizing LCP
LCP is usually the main blocker. Focus here first.
Strategy 1: Optimize Largest Element
Identify what's causing slow LCP:
// Measure element rendering
const observer = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (entry.entryType === 'largest-contentful-paint') {
console.log('LCP Element:', entry.element)
console.log('LCP Size:', entry.size)
console.log('LCP Time:', entry.renderTime || entry.loadTime)
}
}
})
observer.observe({ entryTypes: ['largest-contentful-paint'] })
Usually the largest element is:
- Hero image
- Main heading
- Large text block
Strategy 2: Preload Critical Resources
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
link: [
// Preload hero image
{
rel: 'preload',
as: 'image',
href: '/hero.webp',
imagesrcset: '/hero-mobile.webp 600w, /hero-desktop.webp 1200w',
imagesizes: '(max-width: 600px) 600px, 1200px'
},
// Preload critical fonts
{
rel: 'preload',
as: 'font',
href: '/fonts/inter.woff2',
type: 'font/woff2',
crossorigin: ''
},
// DNS prefetch for external APIs
{
rel: 'dns-prefetch',
href: 'https://api.example.com'
},
// Preconnect to critical domains
{
rel: 'preconnect',
href: 'https://api.example.com'
}
]
}
}
})
Strategy 3: Server-Side Rendering
SSR ensures content is in HTML, not dependent on JavaScript:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// Cache static pages at build time
'/**': { cache: { maxAge: 60 * 10 } },
'/api/**': { noCache: true },
'/admin/**': { ssr: false } // Client-side only for admin
}
})
Strategy 4: Image Optimization
Hero images often block LCP. Optimize aggressively:
<script setup>
import { useImage } from '#app'
</script>
<template>
<div class="hero">
<!-- Use NuxtImg for automatic optimization -->
<NuxtImg
src="/hero.jpg"
alt="Hero"
sizes="xs:100vw md:800px"
quality="80"
format="webp,jpg"
loading="eager"
fetchpriority="high"
/>
</div>
</template>
<style scoped>
.hero {
/* Prevent layout shift while image loads -->
aspect-ratio: 16 / 9;
}
</style>
Optimizing FID (and INP)
FID measures JavaScript execution blocking input. Reduce with:
1. Code Splitting
// nuxt.config.ts
export default defineNuxtConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor': ['lodash-es', 'date-fns'],
'charts': ['chart.js']
}
}
}
}
})
2. Web Workers for Heavy Tasks
// composables/useExpensiveCalculation.ts
export const useExpensiveCalculation = () => {
const result = ref(null)
const processing = ref(false)
const calculate = async (data: any) => {
processing.value = true
// Offload to worker
const worker = new Worker(new URL('@/workers/calculation.ts', import.meta.url), {
type: 'module'
})
worker.postMessage(data)
await new Promise<void>((resolve) => {
worker.onmessage = (e) => {
result.value = e.data
processing.value = false
worker.terminate()
resolve()
}
})
}
return { result, processing, calculate }
}
3. Defer Non-Critical JavaScript
<script setup>
// Track analytics only after page is interactive
onMounted(() => {
if (window.requestIdleCallback) {
requestIdleCallback(() => {
// Load analytics after browser is idle
window.gtag?.('event', 'page_view')
})
} else {
setTimeout(() => {
window.gtag?.('event', 'page_view')
}, 2000)
}
})
</script>
Optimizing CLS
CLS is layout instability. Causes:
- Images without dimensions — add width/height
- Ads loading late — reserve space
- Fonts loading — use
font-display: swap - Dynamic content — skeleton screens
Fix 1: Image Dimensions
<!-- ❌ Causes CLS -->
<img src="photo.jpg" alt="Photo" />
<!-- ✅ No CLS -->
<img src="photo.jpg" alt="Photo" width="800" height="600" />
<!-- Or use aspect-ratio -->
<div class="image-container">
<img src="photo.jpg" alt="Photo" />
</div>
<style>
.image-container {
aspect-ratio: 800 / 600;
}
</style>
Fix 2: Font Loading
// Use font-display: swap to show text immediately
export default defineNuxtConfig({
app: {
head: {
link: [
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'
}
]
}
}
})
Fix 3: Skeleton Screens
<script setup>
const post = ref(null)
const loading = ref(true)
onMounted(async () => {
post.value = await fetchPost()
loading.value = false
})
</script>
<template>
<article v-if="loading" class="post-skeleton">
<div class="skeleton-title" />
<div class="skeleton-text" />
<div class="skeleton-text" />
</article>
<article v-else>
<h1>{{ post.title }}</h1>
<p>{{ post.content }}</p>
</article>
</template>
<style scoped>
.skeleton-title {
width: 80%;
height: 2rem;
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 0.25rem;
margin-bottom: 1rem;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
Real-World Results
After implementing these optimizations:
| Metric | Before | After |
|---|---|---|
| LCP | 3.8s | 1.6s |
| FID | 180ms | 45ms |
| CLS | 0.22 | 0.04 |
| PageSpeed Score | 42 | 92 |
Continuous Monitoring
Set up alerts for regressions:
// Lighthouse CI
npm install -D @lhci/cli
# lhciconfig.json
{
"ci": {
"collect": {
"url": ["https://yoursite.com"],
"numberOfRuns": 3
},
"assert": {
"preset": "lighthouse:recommended",
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"first-input-delay": ["error", { "maxNumericValue": 100 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
}
}
}
}
Run in CI:
# .github/workflows/lighthouse.yml
- name: Run Lighthouse CI
run: lhci autorun
Key Takeaways
- Measure real user data — lab data differs from production
- LCP is usually the bottleneck — focus optimization efforts there
- Images matter most — proper optimization yields biggest gains
- Preload critical resources — hero images and fonts
- Monitor continuously — regressions happen. Catch them early
Core Web Vitals are no longer just SEO metrics—they're user experience fundamentals. Invest in getting them right.