Nuxt Performance: LCP Under One Second

What LCP actually measures, how to identify the critical element, and the Nuxt-specific techniques that make the difference between 2.5s and under 1s.

NuxtPerformanceCore Web VitalsSSR

What LCP is and what it actually measures

Largest Contentful Paint (LCP) measures the time until the largest visible content element in the viewport is fully rendered. It's the moment a user perceives that "something is there" — not the first pixel, not the complete layout, but the dominant visual element.

LCP elements are typically:

  • An image (<img>, background-image, poster in <video>)
  • A large text block (heading, hero text)
  • An element with a background image via CSS

What LCP is not: First Contentful Paint (FCP). FCP measures the first pixel of content at all. A spinner or a loading skeleton can improve FCP without touching LCP — they're too small to become LCP elements.

Google's thresholds: Green under 2.5s. Orange up to 4s. Red above that. Under 1s is ambitious, but achievable on an SSG or well-configured SSR site.

Rendering strategy per route: SSR vs SSG vs ISR

The rendering strategy has the single biggest impact on LCP. Statically generated HTML requires no server computation time — it's served immediately from the CDN. An SSR request is only as fast as the server plus network.

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/':           { prerender: true },   // SSG: maximum performance
    '/blog/**':    { swr: 3600 },         // ISR: cached, revalidated hourly
    '/dashboard':  { ssr: true },         // SSR: always fresh
    '/admin/**':   { ssr: false },        // CSR: no SEO requirements
  }
})

For content pages (blog, marketing, portfolio), prerender: true is the right choice. The HTML is generated at build time, sits statically on the CDN, and the browser gets it without a server roundtrip.

ISR (swr: N) is the compromise: pages are cached and regenerated in the background after N seconds. The first request after the cache expires gets the old version while the new one is being built. The next request gets the new version.

The difference for LCP: a cached SSG page can achieve LCP values under 500ms. An unconfigured SSR endpoint with a cold start can take 1.5–3s before the first byte arrives.

Identifying the LCP element

Before optimizing, you need to know what the LCP element is. Chrome DevTools show it directly:

  1. Open DevTools → Performance tab
  2. Reload the page (with Record active)
  3. Search for "LCP" in the flame chart
  4. The highlighted element is the LCP element

Alternatively: Lighthouse in DevTools. In the Performance report, Lighthouse shows the LCP element with a screenshot, selector, and precise timestamp.

Common surprise: the LCP element is often not the image you think it is. A large heading often beats a medium-sized image. A background-image on a hero container can become the LCP element, even if you don't think of it as an "image."

Once you know the element, there are two categories of optimization: network (the resource needs to arrive faster) and render-blocking (the browser needs to be able to render faster).

If the LCP element is an image, <NuxtImg> with the priority prop is the single most important fix:

<NuxtImg
  src="/hero.jpg"
  alt="Hero Image"
  priority
  width="1200"
  height="600"
  sizes="100vw"
/>

What priority does:

  1. Inserts a <link rel="preload" as="image"> in the <head>
  2. Sets fetchpriority="high" on the <img> element
  3. Disables lazy loading for this image

Without priority, the browser starts loading the image after building the layout tree — typically several hundred milliseconds later. With the preload link, the download begins as soon as HTML parsing starts.

The sizes attribute is not optional here: it tells the browser how large the image will be in the viewport, so it can choose the right resolution from the srcset without waiting for CSS rendering.

<!-- Image takes full width on mobile, half width on desktop -->
<NuxtImg
  src="/feature.jpg"
  priority
  sizes="(max-width: 768px) 100vw, 50vw"
  width="800"
  height="400"
/>

Font loading: font-display, preconnect, subsetting

Fonts are a common LCP blocker. When the browser wants to render the LCP element and needs a font to do it, it waits. This is called Flash of Invisible Text (FOIT).

Three measures, in this order:

1. font-display: swap or optional

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap; /* fallback font immediately, Inter when loaded */
}

swap shows the fallback font immediately and swaps it out once the custom font loads. This prevents FOIT and improves LCP.

2. preconnect for external font hosts

<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

When using Google Fonts or similar external sources, the browser only establishes the connection (DNS + TLS) when it makes the @font-face request. An early preconnect saves 100–300ms.

3. Font subsetting

A complete Inter font file is 200–400KB. The Latin subset with the most common characters is 20–40KB. With unicode-range, the browser only loads the needed subset:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC;
}

Even better: self-hosted fonts via Fontsource in Nuxt — no external host, no CORS, no tracking.

Critical CSS — what Nuxt provides and what you need to do yourself

Critical CSS is the CSS required for above-the-fold content. The browser needs it before it can render — everything else can be loaded afterwards.

Nuxt automatically extracts Critical CSS during the build and embeds it inline in <head>. This means: no render-blocking from external CSS files for the initial view.

But: Nuxt can only extract Critical CSS that is statically analyzable. Dynamic classes that are generated at runtime may be missing from Critical CSS.

<!-- Statically analyzable — Nuxt can include it in Critical CSS -->
<div class="text-lg font-bold text-neutral-900">

<!-- Dynamic — might be missing -->
<div :class="isActive ? 'bg-blue-500' : 'bg-neutral-200'">

For the latter case: Tailwind's safelist in tailwind.config.ts ensures certain classes are always in the CSS bundle.

routeRules hybrid patterns for real sites

Real sites are heterogeneous: the home page needs SSG, the blog needs ISR, the dashboard needs CSR, and a specific API endpoint needs no caching layers.

A complete routeRules setup for a typical Nuxt agency website:

export default defineNuxtConfig({
  routeRules: {
    // Static marketing pages: maximum performance via CDN
    '/':              { prerender: true },
    '/about':         { prerender: true },
    '/contact':       { prerender: true },

    // Blog: revalidated hourly
    '/blog':          { swr: 3600 },
    '/blog/**':       { swr: 3600 },

    // Dynamic user pages: SSR, no caching
    '/profile/**':    { ssr: true, headers: { 'cache-control': 'no-store' } },

    // Admin: pure CSR, no SSR overhead
    '/admin/**':      { ssr: false },

    // API: CORS headers, caching per endpoint
    '/api/public/**': { cors: true, cache: { maxAge: 60 } },
    '/api/auth/**':   { cache: false },
  }
})

Important: prerender: true and swr are mutually exclusive at the page level. ISR (swr) renders at request time (or uses the cached version), SSG renders at build time.

Measure, don't guess: Lighthouse CI in the CI/CD pipeline

Performance optimizations without measurement are guesswork. Lighthouse CI makes performance measurement an automated part of the development process.

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push, pull_request]

jobs:
  lhci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install & Build
        run: |
          npm install
          npm run build

      - name: Run Lighthouse CI
        run: |
          npm install -g @lhci/cli
          lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      startServerCommand: 'node .output/server/index.mjs',
      url: ['http://localhost:3000/', 'http://localhost:3000/blog'],
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'categories:performance': ['error', { minScore: 0.9 }],
        'largest-contentful-paint': ['warn', { maxNumericValue: 2500 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
      }
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
}

With this configuration, a PR fails if the performance score drops below 90 or LCP rises above 2.5s. This is the only method that ensures performance regressions are caught before they reach production.

The goal of "under 1s" is often hard to test directly in Lighthouse because lab conditions differ from real network conditions. Real User Monitoring (RUM) via Google Search Console or web-vitals.js gives a more accurate picture of the actual user experience.