LCP Vue 3 + Vite

Fix LCP in Vue 3

LCP in Vue 3 apps built with Vite is most commonly caused by hero images loaded without priority hints, oversized initial JavaScript bundles that delay first render, and all route components bundled into a single file. Vue's composition API and Vite's bundler are both performance-friendly, but the build configuration determines how much JavaScript the browser must parse before painting the LCP element. These five fixes address the highest-impact causes.

Expected results

Before

3.6s

LCP (Poor) -- no preload, all routes in one bundle, unoptimized images

After

1.4s

LCP (Good) -- preloaded hero, lazy routes, split vendor chunks

Step-by-step fix

Preload the hero image with fetchpriority

The browser discovers images referenced inside Vue components late -- after it downloads, parses, and executes JavaScript. For the LCP image, add a rel="preload" link directly in index.html so the browser starts fetching it from the HTML stream. Always add fetchpriority="high" on the <img> element and never use loading="lazy" on the LCP candidate.

HTML and Vue -- Hero image preloading
<!-- index.html: preload static LCP image at document level -->
<link
  rel="preload"
  as="image"
  href="/images/hero.webp"
  fetchpriority="high"
/>

<!-- For responsive heroes, preload the correct srcset -->
<link
  rel="preload"
  as="image"
  imagesrcset="/hero-480.webp 480w, /hero-800.webp 800w, /hero-1200.webp 1200w"
  imagesizes="100vw"
  fetchpriority="high"
/>

<!-- Vue component: set fetchpriority, disable lazy loading -->
<template>
  <img
    src="/images/hero.webp"
    alt="Product hero"
    width="1200"
    height="600"
    fetchpriority="high"
    loading="eager"
    decoding="async"
  />
</template>

Split routes with dynamic imports

Statically importing all Vue Router views at the top of router/index.js includes every page's component tree in the initial bundle. Switching to dynamic imports makes Vite create a separate JS chunk per route. The browser only downloads the current route's chunk, reducing initial parse time from seconds to milliseconds.

JavaScript -- Vue Router lazy loading
// router/index.js

// Bad: all views in the initial bundle
import Home from '@/views/Home.vue';
import Dashboard from '@/views/Dashboard.vue';
import ProductPage from '@/views/ProductPage.vue';

// Good: each route loaded on demand
import { createRouter, createWebHistory } from 'vue-router';

const routes = [
  {
    path: '/',
    // Vite splits this into home.[hash].js
    component: () => import('@/views/Home.vue'),
  },
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
  },
  {
    path: '/products/:id',
    component: () => import('@/views/ProductPage.vue'),
  },
];

export default createRouter({
  history: createWebHistory(),
  routes,
});

Optimize Vite's chunk splitting

Vite's default behavior creates a single large vendor chunk mixing all node_modules dependencies. Splitting this into separately-named chunks (Vue core, UI libraries, utilities) enables aggressive long-term caching -- each chunk only invalidates when its specific library updates. Add manualChunks to vite.config.js.

JavaScript -- vite.config.js
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          // Vue runtime: rarely updates, cache indefinitely
          if (
            id.includes('node_modules/vue/') ||
            id.includes('node_modules/@vue/') ||
            id.includes('node_modules/vue-router/') ||
            id.includes('node_modules/pinia/')
          ) {
            return 'vue-vendor';
          }
          // Heavy UI framework in its own chunk
          if (id.includes('node_modules/@headlessui/')) {
            return 'ui-vendor';
          }
          // Remaining node_modules
          if (id.includes('node_modules/')) {
            return 'vendor';
          }
        },
      },
    },
    chunkSizeWarningLimit: 200,
  },
});

Add preconnect hints for external origins

If the LCP element depends on resources from external origins -- Google Fonts, an image CDN, or an API server -- the browser must complete a full DNS lookup, TCP handshake, and TLS negotiation (300-500ms combined) before fetching the first byte. rel="preconnect" starts those connections in parallel with other work, eliminating the sequential wait.

HTML -- index.html preconnect hints
<!-- index.html <head> -->

<!-- Font providers -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

<!-- Image CDN -->
<link rel="preconnect" href="https://cdn.yourdomain.com">

<!-- API server (if LCP depends on a network call) -->
<link rel="preconnect" href="https://api.yourdomain.com">

<!-- dns-prefetch as fallback for older browsers -->
<link rel="dns-prefetch" href="https://cdn.yourdomain.com">

<!-- Limit preconnects to 2-3 critical origins.
     Too many preconnects compete for bandwidth and backfire -->

Serve images in WebP with explicit dimensions

JPEG and PNG files are 2-5x larger than equivalent WebP or AVIF images. Smaller files finish downloading sooner, directly reducing LCP. Additionally, images without width and height attributes cause layout reflow as they load (CLS) and miss the browser's aspect-ratio reservation that improves LCP timing. Use the <picture> element for format negotiation.

Vue template -- Modern image format with dimensions
<template>
  <!-- Use <picture> for AVIF with WebP fallback -->
  <picture>
    <source
      srcset="/hero-480.avif 480w, /hero-1200.avif 1200w"
      sizes="100vw"
      type="image/avif"
    />
    <source
      srcset="/hero-480.webp 480w, /hero-1200.webp 1200w"
      sizes="100vw"
      type="image/webp"
    />
    <img
      src="/hero-1200.jpg"
      alt="Hero image"
      width="1200"
      height="600"
      fetchpriority="high"
      loading="eager"
    />
  </picture>
</template>

<!-- Vite plugin for automatic image optimization -->
<!-- npm install vite-plugin-imagemin -->
<!-- vite.config.js: import viteImagemin from 'vite-plugin-imagemin' -->

Quick checklist

  • Hero image has rel="preload" in index.html and fetchpriority="high" on the <img>
  • All Vue Router routes use () => import() dynamic imports
  • manualChunks separates Vue core, router, and UI libraries into named chunks
  • Preconnect hints added for fonts, CDN, and API origins
  • Hero image served in WebP/AVIF with explicit width and height attributes
  • LCP element is not inside a lazy-loaded component or behind a v-if on first render

Frequently asked questions

The most common causes in Vue 3 (Vite) apps are: hero images discovered late because they are rendered by a Vue component rather than being in the initial HTML, large JavaScript bundles that delay the first render, all route components bundled together, missing preconnect hints for external origins, and images served in JPEG or PNG without dimension attributes.

Vue 3's reactivity system has minimal impact on LCP. The Proxy-based system is fast, and with tree-shaking in Vite the Vue runtime is under 20KB. The biggest LCP wins come from bundle splitting and image optimizations -- not from the reactivity overhead.

Nuxt adds SSR and SSG which significantly improve LCP by delivering fully-rendered HTML rather than a blank page. For content-heavy sites with a predictable LCP element, Nuxt's SSG mode produces the best scores. For highly interactive SPAs where SSR is impractical, the Vite optimizations in this guide are the best approach without a framework switch.

Use Chrome DevTools Performance tab with 4x CPU throttling and Slow 4G network. For consistent lab measurements, run Lighthouse from the command line. For field data, install the web-vitals npm package and call onLCP() in your main.js to capture real user LCP values and send them to your analytics endpoint.

defineAsyncComponent is ideal for below-the-fold or interaction-triggered components. For the component containing the LCP element itself, keep it in the synchronous initial bundle so it renders immediately. Use defineAsyncComponent for sidebars, modals, and anything the user will not see on first load.

Google rates LCP as 'good' when it is under 2.5 seconds at the 75th percentile. For Vue applications specifically, aim for under 2.0 seconds. Measure with field data from Chrome User Experience Report (CrUX) through PageSpeed Insights, as lab tests may not reflect real-user experience with third-party scripts and varying network conditions.

Continue learning