How to Fix LCP Over 2.5s in Vue and Nuxt Apps
This guide adapts the general workflow in Measuring LCP with Chrome DevTools to Vue 3 and Nuxt 3/4 applications, as part of Core Web Vitals & Measurement. It is the Vue counterpart to how to fix LCP over 2.5s on React apps.
Vue applications fail LCP in two distinct shapes. A plain Vue SPA (Vite + Vue Router, no SSR) ships an empty <div id="app">, so LCP waits for the entire bundle, the router, the data and the hero image in sequence — routinely 4s or more on mobile. A Nuxt application renders HTML on the server, so the hero is in the document, but LCP can still miss 2.5s because of a client-only hero component, an image module configured to lazy-load, a payload that dwarfs the HTML, or a hero that is replaced during hydration.
Rapid Diagnosis
- View the raw HTML.
curl -s https://example.com/ | grep -c 'hero'— if the hero markup is missing, it is client-rendered (SPA,<ClientOnly>,ssr: falseroute rule, or a component guarded byonMounted). - Find the LCP element in the Performance panel and note whether it is an image (check its
loadingattribute in Elements) or text. - Check the document size vs payload. In Network, compare the HTML document with
_payload.jsonor the inline__NUXT_DATA__script. A payload several times the size of the rendered HTML delays hydration and competes with the hero image for bandwidth. - Look for a hydration mismatch warning in the console (
Hydration node mismatch,Hydration text content mismatch). Mismatches make Vue patch the DOM, which can repaint the hero later than its SSR paint. - Check image priority. On the hero image request, the Priority column should read "High". "Low" means it is lazy or being discovered late.
Root Cause Analysis
1. Client-only hero. A carousel, a personalised banner or a component using window is wrapped in <ClientOnly> or mounted conditionally in onMounted. The server renders a placeholder; the real hero appears only after hydration.
2. NuxtImg/NuxtPicture with lazy loading. @nuxt/image components are often configured site-wide with loading="lazy". Applied to the hero, this defers the request until layout confirms visibility — after CSS and often after other images.
3. Payload bloat. useAsyncData and useFetch serialise every fetched object into the payload. Returning full API objects (with fields the template never reads) can produce a 300KB+ payload that must download and parse before hydration completes.
4. Plain SPA with sequential loading. Without SSR, the hero URL is unknown until the component renders after data loads — the browser's preload scanner can never find it.
Step-by-Step Resolution
Ordered by typical impact. Apply the first that matches your diagnosis, measure, then continue.
1. Server-render the hero (or prerender the route)
For a plain Vue SPA, the largest single win is rendering the above-the-fold HTML ahead of JavaScript — via Nuxt, vite-ssg for static routes, or prerendering marketing pages. In Nuxt, remove <ClientOnly> from the hero and make it SSR-safe instead.
<!-- Before: the hero only exists after hydration -->
<ClientOnly><HeroCarousel :slides="slides" /></ClientOnly>
<!-- After: render the first slide on the server; enhance on the client -->
<HeroCarousel :slides="slides" :initial="0" />
<script setup>
// inside HeroCarousel: guard browser APIs instead of the whole component
const autoplay = ref(false);
onMounted(() => { autoplay.value = !matchMedia('(prefers-reduced-motion)').matches; });
</script>
<!-- trade-off: SSR-safe components must not read window/localStorage during
setup. Personalised heroes need a server-side decision (cookie, edge
logic) or a neutral default that the client upgrades without layout shift. -->
Expected outcome: LCP for SPA routes drops by 1–2.5s on mid-tier mobile; for Nuxt pages with a ClientOnly hero, by the hydration time (typically 400–1200ms).
2. Make the hero image eager and high priority
<NuxtImg
src="/hero/spring-sale.jpg"
sizes="100vw sm:100vw md:1200px"
width="1200" height="600"
format="avif,webp"
loading="eager"
fetchpriority="high"
preload
/>
<!-- trade-off: the preload prop emits a <link rel="preload"> in the head. Use it
only for the single LCP image per template — preloading several images
splits bandwidth and slows all of them. -->
Expected outcome: the hero request starts during HTML parsing instead of after layout; load delay typically falls 200–600ms. The underlying mechanics are covered in using fetchpriority to prioritize the LCP image.
3. Trim the payload to what the template uses
// Before: the whole product object (reviews, variants, SEO blobs) lands in the payload.
const { data } = await useFetch(`/api/products/${id}`);
// After: pick only the fields the page renders above and below the fold.
const { data } = await useFetch(`/api/products/${id}`, {
pick: ['id', 'name', 'price', 'heroImage', 'summary'],
});
// trade-off: pick runs after the fetch on the server, so the API still does the
// full work. For big savings, add a lean endpoint or a ?fields= parameter so
// the server does not build data it will discard.
Expected outcome: payload size typically falls 50–90%, shortening hydration and freeing bandwidth for the hero image. Reducing Nuxt payload size covers the remaining techniques.
4. Fix hydration mismatches on the hero
Mismatches in the hero (a date formatted differently on server and client, a random slide order, a viewport-dependent class) cause Vue to patch the DOM after hydration, which can produce a new, later LCP candidate.
<!-- Before: different output on server and client -->
<p>{{ new Date().toLocaleDateString() }}</p>
<!-- After: render a stable value on the server, refine on the client -->
<p>{{ publishedIso }}</p>
<NuxtTime :datetime="publishedIso" /> <!-- or format in onMounted -->
<!-- trade-off: deferring client-specific formatting means a brief stable
rendering first. Keep the stable version the same size to avoid CLS. -->
Expected outcome: no hydration warnings; the LCP timestamp matches the first SSR paint instead of a post-hydration repaint.
Verification
Compare before-and-after traces with identical throttling. For SSR fixes, the LCP marker should move ahead of the hydration long task in the main-thread track. For image fixes, the hero request should start within the first few hundred milliseconds of the HTML arriving. Then guard the gain in CI with Lighthouse CI's largest-contentful-paint assertion at 2500 and a payload size budget:
// scripts/check-payload.mjs — fail CI if any prerendered payload exceeds 60KB.
import { globSync, statSync } from 'node:fs';
const big = globSync('.output/public/**/_payload.json').filter((f) => statSync(f).size > 60_000);
if (big.length) { console.error('Payload over budget:', big); process.exit(1); }
// trade-off: a flat 60KB cap is crude — data-heavy pages legitimately need more.
// Set per-route caps for those instead of raising the global one.
In RUM, split LCP by element type and by whether the session was a hard load, so you can confirm the hero-specific fix rather than a general improvement.
Vue-Specific Pitfalls Worth Checking
v-if on the hero during SSR. A hero guarded by a reactive flag that is false on the server (for example, a store value populated in a client plugin) renders nothing in the HTML. Initialise the flag on the server or use v-show so the markup is present.
Global defineAsyncComponent for layout pieces. Making the header or hero async splits them into separate chunks; during SSR they render fine, but in SPA mode they add a chunk round trip before LCP. Keep above-the-fold components synchronous.
Transitions on page enter. Nuxt's pageTransition with an opacity fade means the hero starts invisible. LCP is not recorded until the element is visible. Disable the transition for the initial load or start it from opacity 1.
Too many useAsyncData calls awaited in sequence. Each top-level await in <script setup> runs in order on the server, adding to TTFB. Run independent fetches in parallel with Promise.all or non-blocking lazy: true for below-the-fold data.
FAQ
Does Nuxt's lazy hydration help LCP?
Indirectly. Lazy hydration (hydrate-on-visible, hydrate-never and similar) reduces main-thread work during startup, which mostly benefits INP and Total Blocking Time. It helps LCP only when hydration work was delaying the hero's paint — for instance a client-patched hero, or a hero image whose request was queued behind script. See lazy hydration in Nuxt.
Is a Vue SPA ever fine for LCP?
For authenticated apps where users arrive at a cached shell repeatedly, yes — a service worker or HTTP-cached bundle removes most of the download cost on repeat visits. For any landing page reached from search or ads, the first visit dominates, and an SPA cannot compete with server-rendered HTML.
Related
- Why the LCP element changes between loads — when hydration produces a second LCP candidate.
- Vue & Nuxt performance — framework-level patterns beyond LCP.
- Diagnosing LCP resource load delay — the phase most image fixes shrink.