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:

  1. LCP (Largest Contentful Paint) — How fast does content appear?
    • Target: < 2.5s
    • Measures: When largest element (image, heading, video) becomes visible
  2. 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
  3. 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

code
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:

bash
npm install web-vitals

Track metrics in Nuxt:

typescript
// 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:

typescript
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:

typescript
// 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

typescript
// 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:

typescript
// 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:

vue
<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

typescript
// nuxt.config.ts
export default defineNuxtConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor': ['lodash-es', 'date-fns'],
          'charts': ['chart.js']
        }
      }
    }
  }
})

2. Web Workers for Heavy Tasks

typescript
// 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

vue
<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:

  1. Images without dimensions — add width/height
  2. Ads loading late — reserve space
  3. Fonts loading — use font-display: swap
  4. Dynamic content — skeleton screens

Fix 1: Image Dimensions

vue
<!-- ❌ 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

typescript
// 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

vue
<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:

MetricBeforeAfter
LCP3.8s1.6s
FID180ms45ms
CLS0.220.04
PageSpeed Score4292

Continuous Monitoring

Set up alerts for regressions:

typescript
// 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:

bash
# .github/workflows/lighthouse.yml
- name: Run Lighthouse CI
  run: lhci autorun

Key Takeaways

  1. Measure real user data — lab data differs from production
  2. LCP is usually the bottleneck — focus optimization efforts there
  3. Images matter most — proper optimization yields biggest gains
  4. Preload critical resources — hero images and fonts
  5. 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.