← Volver al blog

Web Performance in Nuxt 3: where the wins actually are

Rendering strategies in Nuxt 3 (SSR, SSG, ISR) and how to attack LCP, CLS and INP with routeRules, self-hosted fonts and images. Based on the real decisions behind this portfolio.

Web Performance in Nuxt 3: where the wins actually are

Most performance work I see starts in the wrong place. Someone opens Lighthouse, sees a 72, and starts moving things until the number goes up. Compress some images, drop a library, add a lazy here. The number goes up. The site is not faster.

The catch is that Lighthouse measures a lab: a simulated device, a simulated network, a cold load with no cache. What happens to your users lives in the field — the data Chrome actually collects, the data Google ranks on. And the metric that fails there is almost never bundle size.

What follows are the decisions that do move the needle, in the order I make them.


First: what renders, and when

Before optimising anything, you decide how each page is produced. In Nuxt 3 this is not a global switch — it is per route, and that is the nuance people lose.

The anti-pattern is treating the whole application the same:

// ❌ Anti-pattern: everything SSR "because SEO"
// nuxt.config.ts
export default defineNuxtConfig({
  ssr: true,
})

Every hit on every route invokes a server function. A blog post that has not changed in six months gets re-rendered on each request, and the user pays for that time in their LCP.

routeRules lets you decide route by route:

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    routeRules: {
      // Static content: built once, served from the CDN
      '/blog': { prerender: true },
      '/blog/**': { prerender: true },

      // Changes, but tolerates being stale: regenerated in the background
      '/projects': { isr: 3600 },

      // Private area: never cached, no prerender
      '/dashboard/**': { ssr: true, headers: { 'cache-control': 'no-store' } },

      // Isolated widget that needs no server HTML
      '/embed/**': { ssr: false },
    },
  },
})

The four strategies, and what each one costs:

StrategyWhenCost
prerenderContent is known at build timeLonger builds; needs a rebuild to update
isrChanges, but can be a few minutes staleFirst visit after expiry pays the render
ssrPersonalised per user or requestA function on every single visit
ssr: falseBehind a login, no SEO valueBlank screen until hydration

On this portfolio the entire blog is prerender. There is no reason for a server to compute, in real time, an article I wrote three weeks ago. The HTML comes off the CDN, and LCP becomes a network problem rather than a compute problem.

Note: prerender: true in routeRules marks a route as prerenderable, but Nitro still has to find it. crawlLinks: true covers anything reachable by a link. Anything else — feeds, sitemaps, dynamic routes nothing links to — has to be listed in nitro.prerender.routes.


LCP: it is nearly always a font or an image

Largest Contentful Paint measures when the biggest element in the viewport shows up. In practice that element is a headline or a hero image. And what delays it is usually one of two culprits.

Fonts that block

The default pattern almost everyone reaches for is linking Google Fonts from their CDN:

<!-- ❌ Anti-pattern -->
<link href="https://fonts.googleapis.com/css2?family=Manrope&display=swap" rel="stylesheet">

That is two fresh connections before any text can paint: one to fonts.googleapis.com for the CSS, another to fonts.gstatic.com for the .woff2. On a real mobile network, each TLS handshake costs more than the font itself.

The alternative is self-hosting. The Google Fonts module downloads the files at build time and serves them from your own domain:

// nuxt.config.ts
googleFonts: {
  download: true,     // pull the files at build time
  inject: true,       // inline the @font-face — no CSS request
  display: 'swap',
  preload: true,
  subsets: ['latin'], // do not ship Cyrillic you never render
  families: {
    Manrope: { wght: [300] },
    'JetBrains Mono': { wght: [400] },
  },
}

The detail that carries the most weight is families. Asking for Manrope: true pulls every weight — 200 through 800, plus italics — which is hundreds of kilobytes nobody uses. Declaring the exact weights your design actually uses usually saves more than any image optimisation will.

Images without dimensions

<NuxtImg> generates a srcset and serves WebP or AVIF based on what the browser supports. But it only works if you do your part:

<!-- ❌ Anti-pattern: no dimensions, no priority -->
<img src="/images/hero.jpg" alt="Hero">

<!-- Correct -->
<NuxtImg
  src="/images/hero.jpg"
  alt="Hero"
  width="1280"
  height="720"
  format="webp"
  preload
  loading="eager"
/>

width and height are not there to size the element — CSS still wins — but to let the browser reserve the space before the download finishes. Without them, content jumps when the image lands, and that is CLS.

preload and loading="eager" belong only on the above-the-fold image. Applying them everywhere is worse than not applying them at all: you compete with yourself for bandwidth and delay the one that mattered.


CLS: the jump you cause yourself

Cumulative Layout Shift accumulates every unexpected movement of content. Undimensioned images are the classic cause, but there is one that sneaks into nearly every project with a dark theme: the theme switcher itself.

If the theme is read inside a composable during hydration, the user sees the site in light mode and then it snaps to dark. Technically it is not CLS — no layout moves — but it is exactly as jarring, and it is a textbook FOUC.

The only way to avoid it is resolving the theme before the first paint, with a synchronous script in the <head>:

// nuxt.config.ts — app.head.script
{
  innerHTML: `(function(){
    var d = document.documentElement
    var t = localStorage.getItem('theme')
    if (t !== 'light' && t !== 'dark') {
      t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
    }
    d.setAttribute('data-theme', t)
  })()`,
  type: 'text/javascript',
}

Yes, it is an inline blocking script. Blocking here is the right call: microseconds of reading localStorage in exchange for removing a flash the user reads as a bug. This is the rare case where the answer to "should I block rendering?" is yes.


INP: the metric you do not see coming

Since 2024, INP (Interaction to Next Paint) replaced FID in Core Web Vitals. It is a harsher measure: FID only captured the delay before processing the first interaction, while INP measures the full interaction — click to painted response — and keeps the worst one across the session.

What usually breaks it in a Nuxt app is not your business logic. It is scroll listeners:

// ❌ Anti-pattern: layout work on every scroll frame
window.addEventListener('scroll', () => {
  const sections = document.querySelectorAll('section')
  sections.forEach((section) => {
    const rect = section.getBoundingClientRect() // forces reflow
    if (rect.top < window.innerHeight / 2) setActive(section.id)
  })
})

getBoundingClientRect() forces the browser to recalculate layout, and there it sits inside a handler firing dozens of times per second. The main thread saturates and any click the user makes has to wait.

IntersectionObserver does the same job without touching the main thread:

// composables/useSectionSpy.ts
export function useSectionSpy(selector: string) {
  const active = ref<string | null>(null)

  onMounted(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) active.value = entry.target.id
        }
      },
      { rootMargin: '-50% 0px -50% 0px' },
    )

    document.querySelectorAll(selector).forEach(el => observer.observe(el))
    onUnmounted(() => observer.disconnect())
  })

  return { active }
}

The browser computes intersections off the main thread and only calls you when something crosses the threshold. The general rule: if you are writing addEventListener('scroll'), there is almost always an observer that does that job better.

Where a handler is still justified: if you already run a smooth-scroll library like Lenis, your page lives inside its own requestAnimationFrame loop, and an observer does not always stay in sync with the interpolated position the library is painting. A handler can be the right call there — but then the work inside it has to be minimal. Cache the rects on mount and on resize; do not recompute them every frame. The sin is not the listener; it is calling getBoundingClientRect() inside it.


What is not worth optimising

Knowing where not to spend the effort matters as much as knowing where to spend it:

  • Shaving a bundle from 180 kB to 160 kB. On a decent connection that is milliseconds. The same effort spent on rendering strategy is worth ten times more.
  • Chasing a perfect 100 in Lighthouse. Going from 95 to 100 lives in details no user perceives. Going from 60 to 90 does get noticed.
  • Micro-optimising computed. Vue 3 caches aggressively. If you have a reactivity problem, it is architectural, not a question of whether you wrapped something in a computed.
  • Preloading everything. preload is a budget. Preload ten resources and you have prioritised none.

When to apply what

ScenarioStrategy
Blog or marketing siteprerender everything + self-hosted fonts
E-commerce with a large catalogueisr on product pages, prerender on static ones
Dashboard behind a loginssr: false and focus on INP
App with per-user contentssr + server-side data caching

Conclusion

The order I work in, and rarely deviate from:

  1. Decide the rendering strategy per route before touching anything else. It is the only decision with an order-of-magnitude difference behind it.
  2. Self-host your fonts and declare exact weights. Usually the single biggest LCP win available.
  3. Give every image dimensions, and prioritise only the one above the fold.
  4. Resolve the theme before first paint, even if that means a blocking script.
  5. Replace scroll listeners with observers — that is where INP is won or lost.
  6. Measure in the field, not the lab. Lighthouse points you in a direction; CrUX is what your users actually got, and what Google sees.

What is not on this list — compressing a bit harder, dropping a dependency — is not wrong. It just comes afterwards, and it rarely changes anything.

Questions, or want to see a specific case? Write to me at hola@miguel-jimenez.dev.