Lazy Loading Routes and Components in Vue
AI generated
<v/>
{ }
Vue.js · Vue Router · Lazy Loading · Performance
Lazy Loading Routes and Components
Loading content only once users truly need it

Lazy loading pushes the loading of routes, components, images and data to the moment a user actually sees or requests them. With Vue Router, defineAsyncComponent, Intersection Observer and Suspense, this principle can be applied consistently throughout an entire application.

18 min read Vue Router · defineAsyncComponent · Intersection Observer Vue 3.4+ · Vue Router 4

1. What lazy loading actually means in Vue apps

Lazy loading moves the loading of a resource, whether a route, a component, an image or a dataset, from the moment of the first page visit to the moment it is actually needed. The opposite is eager loading, where everything is loaded immediately regardless of whether the user ever sees it in the current session. In Vue apps, lazy loading touches several layers at once: the router layer, the component layer and the media layer, and each layer needs its own technical tool.

The benefit of lazy loading shows up most clearly in applications with many routes or long, image heavy pages. A user opening the home page of a Vue app does not need the code for the settings page, the admin area or a rarely used export form loaded alongside it. Likewise, an image that only becomes visible after several screen heights of scrolling does not need to be downloaded immediately if the user might never scroll that far. Lazy loading thus reduces the initial data volume and speeds up the moment the page becomes interactive.

It is important to distinguish this from bundle splitting: bundle splitting is the build time technique that creates separate files in the first place. Lazy loading is the runtime decision about exactly when these separate files are requested. Both concepts complement each other, but lazy loading additionally needs a deliberate choice of trigger, such as a navigation, visibility in the viewport, or a user interaction.

2. Lazy loading routes with Vue Router

The router is the most obvious place for lazy loading, because every route already represents a natural boundary for needed code. Vue Router supports dynamic imports as a component definition directly, with no extra configuration: instead of importing a component at the top of a file, you pass a function that returns a Promise resolving to the component. On the first navigation to that route, the browser fetches the associated chunk, on every further navigation it is already cached.

For nested routes with many sub routes, a consistent structure is worthwhile: every top level route and every significant sub route gets lazy loaded, while small, closely related sub routes that are almost always visited together can stay in the same chunk. This decision is best made after briefly analyzing actual user navigation behavior, not purely by file structure.


// router/index.js — lazy loading every top level route
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { path: '/', component: () => import('../views/Home.vue') },
  { path: '/products', component: () => import('../views/Products.vue') },
  {
    path: '/admin',
    // Nested routes: only the parent is fetched first,
    // children are fetched when their specific path is visited
    children: [
      { path: 'users', component: () => import('../views/admin/Users.vue') },
      { path: 'settings', component: () => import('../views/admin/Settings.vue') }
    ]
  }
]

export const router = createRouter({
  history: createWebHistory(),
  routes
})

3. Lazy loading components with defineAsyncComponent

Inside an already loaded route, there are often components that are not immediately visible: a modal, a tab content shown only after a click, or a complex editor that only appears in a specific editing mode. defineAsyncComponent loads exactly these components only on the first render attempt, not when the surrounding route renders. For lazy loading at the component level, this is the most important API and works independently of the router.

A practical pattern: tab based interfaces where each tab's content is its own, potentially heavy component. Only the active tab is actually rendered, all other tabs remain unloaded as async components until the user actually clicks them. Combined with v-if instead of v-show, you additionally prevent inactive tabs from existing in the DOM at all, which further lowers memory usage.


// components/TabbedSettings.vue — each tab is loaded on first activation
import { defineAsyncComponent, ref } from 'vue'

const GeneralTab = defineAsyncComponent(() => import('./tabs/GeneralTab.vue'))
const BillingTab = defineAsyncComponent(() => import('./tabs/BillingTab.vue'))
const SecurityTab = defineAsyncComponent(() => import('./tabs/SecurityTab.vue'))

const activeTab = ref('general')
const tabs = { general: GeneralTab, billing: BillingTab, security: SecurityTab }

// v-if in the template ensures inactive tabs never mount,
// so their async component never even starts loading

4. Controlling prefetching and preloading deliberately

Pure lazy loading has one downside: the user waits for the chunk once they actually navigate. Prefetching closes this gap by loading chunks before they are needed, but after the critical resources for the current view have already loaded. Vite automatically adds <link rel="modulepreload"> hints for the direct dependencies of every dynamic import, which is already a basic form of preloading.

For deliberate prefetching on user interaction, a pattern is worthwhile that triggers the import already on mouseenter over a link, well before the actual click happens. With average reaction times between hover and click of several hundred milliseconds, the chunk is in many cases already fully loaded by the time the click actually occurs, meaning lazy loading and perceived speed do not exclude each other, they complement each other.


// composables/usePrefetchOnHover.js
export function usePrefetchOnHover(importFn) {
  let prefetched = false
  return {
    onMouseenter: () => {
      if (!prefetched) {
        prefetched = true
        importFn() // starts the chunk download ahead of the actual click
      }
    }
  }
}

// Usage in a component
// const { onMouseenter } = usePrefetchOnHover(() => import('../views/Checkout.vue'))
// <router-link to="/checkout" @mouseenter="onMouseenter">Checkout</router-link>

5. Lazy loading images and media

Images account for the largest share of transferred data in many Vue apps, which is why lazy loading here often has a bigger effect than for JavaScript chunks. The native way is the loading="lazy" attribute on <img> tags, evaluated by the browser itself without any JavaScript needed. For Vue templates this simply means consistently setting the attribute on all images below the visible area, while images in the initially visible area, such as a hero image, are deliberately left without loading="lazy" so they are not artificially delayed.

For videos and heavy embedded content such as maps or third party widgets, the native attribute is not enough. Here lazy loading is typically combined with a conditional rendering strategy: a placeholder element is rendered, and the actual video element or iframe is only inserted after a user interaction or after becoming visible in the viewport. This prevents a YouTube iframe from already loading several hundred kilobytes of additional scripts on page load, even though the user may never play the video.


<!-- Native lazy loading for below-the-fold images -->
<template>
  <img
    v-for="product in products"
    :key="product.id"
    :src="product.thumbnail"
    :alt="product.name"
    loading="lazy"
    decoding="async"
  />

  <!-- Hero image stays eager — it is visible immediately -->
  <img :src="heroImage" alt="Hero" loading="eager" fetchpriority="high" />
</template>

6. Intersection Observer for visibility based loading

For components not covered by the native loading="lazy" attribute, such as entire widget areas, comment sections or recommendation lists further down the page, the Intersection Observer API is the right tool. A composable registers an observer on a placeholder element and only loads the actual component once that element scrolls into the visible area. The advantage over scroll event listeners: Intersection Observer does not run on the main thread on every scroll event, it is evaluated efficiently and asynchronously by the browser.

This technique can be elegantly wrapped as a reusable composable that combines with any asynchronous component. For lazy loading in Vue apps, this complements defineAsyncComponent: while defineAsyncComponent ties loading to rendering, the Intersection Observer ties rendering itself to visibility, pushing the actual load time even further back until the user truly scrolls near the content.


// composables/useLazyMount.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useLazyMount() {
  const target = ref(null)
  const isVisible = ref(false)
  let observer

  onMounted(() => {
    observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        isVisible.value = true
        observer.disconnect() // only trigger once
      }
    }, { rootMargin: '200px' }) // start loading slightly before it enters view

    if (target.value) observer.observe(target.value)
  })

  onUnmounted(() => observer?.disconnect())

  return { target, isVisible }
}

// Template usage:
// <div ref="target"><RecommendationsWidget v-if="isVisible" /></div>

7. Loading states, skeletons and Suspense

Lazy loading without visible feedback feels like a broken application to users: a click on a tab or a navigation shows nothing for a moment before content appears. Vue's <Suspense> component solves this by rendering a fallback state while an async component or an async setup() is still loading. For lazy loading in larger applications, Suspense is the central place to define consistent loading states across the entire application, instead of building custom loading logic in every single component.

Skeleton screens that hint at the approximate shape of upcoming content feel faster to users than a simple spinner, even with an identical actual load time. Combining Suspense for the structural loading state with a thematically matching skeleton as the fallback component is the most robust pattern for lazy loading feedback in Vue apps, because it works for both routes and individual async components.


// App.vue — Suspense wraps lazily loaded route content

<template>
  <router-view v-slot="{ Component }">
    <Suspense timeout="0">
      <template #default>
        <component :is="Component" />
      </template>
      <template #fallback>
        <ProductListSkeleton />
      </template>
    </Suspense>
  </router-view>
</template>

8. Common mistakes in lazy loading

The most common mistake is lazy loading content above the first viewport, the so called above the fold element. Adding loading="lazy" to a hero image or the primary navigation component delays exactly the content that should be visible first, worsening Largest Contentful Paint instead of improving it. Lazy loading belongs exclusively to content that is not visible on the first render.

A second mistake concerns missing fallback handling: an async component without a defined error state leaves an empty, confusing gap in the interface on a network error. defineAsyncComponent should almost always be configured with errorComponent and a sensible timeout. A third mistake: Intersection Observer instances that are not cleaned up with disconnect() when the component is removed, leading to memory leaks, especially in single page applications with frequent route changes.

9. Lazy loading strategies compared

Depending on the type of resource, a different lazy loading technique fits best. The following overview ranks the most important approaches by trigger and typical area of use.

Resource Technique Trigger Fallback needed
Route Vue Router dynamic import Navigation Yes, Suspense or progress bar
Component defineAsyncComponent First render Yes, loadingComponent
Image loading="lazy" Proximity to viewport No, handled natively
Widget area Intersection Observer Visibility in viewport Optional, placeholder useful
Likely next route Prefetch on hover Mouseenter on link No, runs in the background

In practice, you combine several of these techniques within a single Vue app: route level lazy loading as the baseline, component level lazy loading for heavy UI areas inside a route, native image attributes for everything below the first viewport, and Intersection Observer for the remaining special cases like embedded third party widgets.

Mironsoft

Lazy loading and load time optimization for Vue applications

Too much code and too many images on the first visit?

We build consistent lazy loading for routes, components and media into your Vue app, including prefetching, skeleton loading states and clean error handling.

Route & component

Consistent lazy loading with Vue Router and defineAsyncComponent

Media optimization

Loading images, videos and widgets based on visibility

Loading states

Suspense and skeleton screens for perceived speed

10. Summary

Lazy loading routes and components is not a single technique, it is the interplay of several tools at different layers. Vue Router fetches routes on navigation, defineAsyncComponent loads heavy components only on their first render, native loading="lazy" attributes defer images below the viewport, and Intersection Observer covers all remaining cases like widget areas. Prefetching on hover complements pure lazy loading so perceived speed does not suffer from deferred loading.

The most important principle remains: lazy loading belongs only to content that does not need to be immediately visible. Above the fold content, most notably hero images and primary navigation, should be deliberately excluded from every lazy loading technique. With Suspense and skeleton screens as a consistent feedback mechanism, lazy loading can be applied so that users perceive a fast, responsive application, even though code and data are continuously loading in the background.

Lazy Loading in Vue Apps, the essentials at a glance

Routes

Dynamic imports in Vue Router load every route only on navigation, combined with Suspense for loading states.

Components

defineAsyncComponent for modals, tabs and heavy UI areas that are not immediately visible.

Media

loading="lazy" for images below the viewport, never for above the fold content.

Feedback

Prefetch on hover and skeleton screens keep speed feeling fast despite lazy loading.

11. FAQ: Lazy Loading Routes and Components

1Lazy loading vs. bundle splitting?
Bundle splitting creates files at build time, lazy loading decides at runtime when they are requested.
2Lazy load every route?
In most cases yes, except very small applications with few routes.
3Avoid delay on routes?
Suspense with a skeleton fallback and prefetching on hover so the chunk is often already available.
4defineAsyncComponent instead of router level?
For heavy, not immediately visible components inside an already loaded route, such as modals.
5Why no lazy hero image?
It is immediately visible, lazy loading would worsen Largest Contentful Paint.
6Why Intersection Observer?
For widget areas and third party components that do not support loading=lazy.
7Network error during lazy loading?
Without errorComponent the spot stays empty. Always configure defineAsyncComponent with errorComponent and timeout.
8Is prefetching always worth it?
Almost always for predictable navigation paths, less so for very broadly branching applications.
9Clean up observers manually?
Yes, with disconnect() in onUnmounted, otherwise memory leaks occur.
10Does it improve Core Web Vitals?
Yes, especially Largest Contentful Paint and Time to Interactive benefit from less initially loaded code.