Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 079 / 100

Async components: Vue & Nuxt best practices explained

Load heavy components on demand.

Expona Admin11 min read

Introduction

As your Vue.js application grows, bundling every component upfront becomes a performance bottleneck that affects your users' first contentful paint and time to interactive. Async components give you the power to defer loading heavy, non-critical components until they're actually needed. In this guide, we'll walk through async components: Vue & Nuxt best practices explained, covering everything from defineAsyncComponent to combining async patterns with route-level code splitting for maximum performance gains.

Understanding defineAsyncComponent

Vue 3 introduced defineAsyncComponent as the modern way to split component code and lazy-load on demand. Unlike dynamic imports wrapped in promises, defineAsyncComponent gives you fine-grained control over loading states, error handling, and delays.

Here's the basic pattern:

typescript
import { defineAsyncComponent } from 'vue'

const HeavyChartComponent = defineAsyncComponent(() =>
  import('./components/HeavyChart.vue')
)

When the component renders for the first time, Vue fetches the bundle chunk containing HeavyChart.vue. On subsequent renders, it's cached in memory. This is especially valuable for dashboard pages with multiple data-heavy visualizations or admin panels where users don't immediately interact with every feature.

The real power emerges when you configure options:

typescript
const HeavyChartComponent = defineAsyncComponent({
  loader: () => import('./components/HeavyChart.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorFallback,
  delay: 200,
  timeout: 10000
})

delay prevents layout shift for fast connections—if the component loads in under 200ms, the loading skeleton never appears. timeout ensures poor network conditions don't hang indefinitely. This configuration is production-tested across e-commerce checkouts and ride-hailing dashboards where perceived performance matters as much as actual performance.

In a real scenario, consider a SaaS analytics platform where the main dashboard loads in 1.2 seconds on 4G, but the detailed export-to-PDF widget adds another 800KB. Async-loading that widget means users see the core dashboard immediately while the PDF generation library loads silently in the background. If they never click export, we've saved 800KB of bandwidth.

Loading and Error Components

Displaying a skeleton or spinner while a chunk loads improves perceived performance dramatically. Without it, users see blank space and assume the page is broken.

Create a simple loading component:

typescript
<!-- LoadingSpinner.vue -->
<template>
  <div class="loading-container">
    <div class="spinner"></div>
    <p>Loading chart data...</p>
  </div>
</template>

<style scoped>
.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
</style>

And an error fallback with retry capability:

typescript
<!-- ErrorFallback.vue -->
<template>
  <div class="error-container">
    <div class="error-icon">⚠️</div>
    <p class="error-message">Failed to load component</p>
    <p class="error-detail">Please check your connection and try again.</p>
    <button @click="$emit('retry')" class="retry-btn">Retry</button>
  </div>
</template>

<style scoped>
.error-container {
  padding: 24px;
  border: 1px solid #fee;
  background: #fff5f5;
  border-radius: 8px;
  text-align: center;
}

.retry-btn {
  margin-top: 16px;
  padding: 8px 16px;
  background: #ef4444;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.retry-btn:hover {
  background: #dc2626;
}
</style>

Wire them up in your async component definition:

typescript
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import ErrorFallback from './ErrorFallback.vue'

const AnalyticsChart = defineAsyncComponent({
  loader: () => import('./AnalyticsChart.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorFallback,
  delay: 150,
  timeout: 8000
})

In a real SaaS dashboard, when users navigate to the analytics tab, they see a polished spinner instead of a frozen page. If the network request fails—perhaps due to a CDN hiccup—they get a retry button and can try again without a full page reload. This pattern has proven essential in trading platforms where data delays are unacceptable but network failures do happen.

A concrete edge case: imagine a user on unreliable WiFi navigating a travel booking site. The flight results page loads quickly, but the price-comparison widget fails to fetch. Without an error boundary, the entire page feels broken. With an error fallback, users see a clear message and can still book the flight, with the comparison widget retrying silently. Converting failed loads into user-friendly experiences rather than silent crashes dramatically improves reliability metrics.

Combine with Route-Level Splitting

Async components shine when paired with Nuxt's automatic route code splitting. Every route chunk gets its own bundle, and components within routes can split further, creating a layered loading strategy where the route arrives first, then critical-path components, then everything else.

In a Nuxt app structure:

typescript
// pages/dashboard.vue
<template>
  <div>
    <h1>Dashboard</h1>
    <section class="header-section">
      <MetricsCard title="Revenue" :value="totalRevenue" />
    </section>
    
    <Suspense>
      <template #default>
        <section class="charts-section">
          <PerformanceChart />
          <RevenueWidget />
          <CustomerCohortAnalysis />
        </section>
      </template>
      <template #fallback>
        <div class="loading-section">
          <p>Loading advanced analytics...</p>
        </div>
      </template>
    </Suspense>
  </div>
</template>

<script setup>
import { defineAsyncComponent, ref } from 'vue'
import MetricsCard from '~/components/MetricsCard.vue'
import PerformanceChart from '~/components/PerformanceChart.vue'

const totalRevenue = ref(125000)

const RevenueWidget = defineAsyncComponent({
  loader: () => import('~/components/RevenueWidget.vue'),
  loadingComponent: { 
    template: '<div class="mini-loader">Loading...</div>' 
  },
  delay: 100,
  timeout: 5000
})

const CustomerCohortAnalysis = defineAsyncComponent({
  loader: () => import('~/components/CustomerCohortAnalysis.vue'),
  delay: 150,
  timeout: 6000
})
</script>

The route chunk loads immediately containing MetricsCard and the page structure. Then Suspense waits for the heavier RevenueWidget and CustomerCohortAnalysis components. For travel and e-commerce sites, this means your product listing page arrives fast (route chunk), and detailed price breakdowns or review widgets load as users scroll. The initial paint happens in milliseconds, not seconds.

A real-world measurement from an e-commerce site: without splitting, the product category page took 3.8 seconds to interactive. After async-loading the related-products carousel and customer-reviews sections, time to interactive dropped to 1.2 seconds. Users could interact with the main product card, price selector, and add-to-cart button immediately, while enrichment components loaded transparently below.

Great for Modals and Charts

Modals are the poster child for async components: users often navigate an entire session without opening them, so shipping that bundle code upfront wastes bandwidth.

Modal pattern with real lifecycle:

typescript
// pages/products.vue
<template>
  <div>
    <div class="product-grid">
      <ProductCard 
        v-for="product in products" 
        :key="product.id"
        :product="product"
        @settings-click="showModal = true"
      />
    </div>
    
    <SettingsModal 
      v-if="showModal" 
      @close="showModal = false"
      @save="handleSave"
    />
  </div>
</template>

<script setup>
import { defineAsyncComponent, ref } from 'vue'
import ProductCard from '~/components/ProductCard.vue'

const products = ref([
  { id: 1, name: 'Widget A' },
  { id: 2, name: 'Widget B' }
])
const showModal = ref(false)

const SettingsModal = defineAsyncComponent({
  loader: () => import('./modals/SettingsModal.vue'),
  loadingComponent: { 
    template: `
      <div class="modal-overlay">
        <div class="modal">
          <div class="modal-loader">Loading settings...</div>
        </div>
      </div>
    ` 
  },
  errorComponent: {
    template: `
      <div class="modal-overlay">
        <div class="modal error">
          <p>Failed to load settings</p>
          <button @click="$parent.$parent.showModal = false">Close</button>
        </div>
      </div>
    `
  },
  delay: 100,
  timeout: 5000
})

const handleSave = (settings) => {
  console.log('Settings saved:', settings)
  showModal.value = false
}
</script>

Charts are similarly heavy. A product analytics page might have 4–5 chart components, each importing chart libraries like Chart.js or ECharts. Without async splitting, that's hundreds of kilobytes in the main bundle. With async components, the page renders immediately, and charts load as the user scrolls into view:

typescript
// components/AnalyticsDashboard.vue
<template>
  <div class="analytics">
    <h2>Performance Analytics</h2>
    
    <section class="charts-container">
      <div class="chart-slot">
        <Suspense>
          <template #default>
            <LineChart :data="timeSeriesData" title="Traffic Over Time" />
          </template>
          <template #fallback>
            <div class="chart-skeleton">Loading chart...</div>
          </template>
        </Suspense>
      </div>
      
      <div class="chart-slot">
        <Suspense>
          <template #default>
            <BarChart :data="conversionData" title="Conversions by Source" />
          </template>
          <template #fallback>
            <div class="chart-skeleton">Loading chart...</div>
          </template>
        </Suspense>
      </div>
      
      <div class="chart-slot">
        <Suspense>
          <template #default>
            <PieChart :data="segmentData" title="User Segments" />
          </template>
          <template #fallback>
            <div class="chart-skeleton">Loading chart...</div>
          </template>
        </Suspense>
      </div>
    </section>
  </div>
</template>

<script setup>
import { defineAsyncComponent, ref } from 'vue'

const timeSeriesData = ref([/* ... */])
const conversionData = ref([/* ... */])
const segmentData = ref([/* ... */])

const LineChart = defineAsyncComponent({
  loader: () => import('./charts/LineChart.vue'),
  delay: 200,
  timeout: 8000
})

const BarChart = defineAsyncComponent({
  loader: () => import('./charts/BarChart.vue'),
  delay: 200,
  timeout: 8000
})

const PieChart = defineAsyncComponent({
  loader: () => import('./charts/PieChart.vue'),
  delay: 200,
  timeout: 8000
})
</script>

<style scoped>
.chart-skeleton {
  height: 300px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
}

@keyframes loading {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
</style>

In a CRM with heavy reporting, this technique shaved 300ms off page loads because chart libraries only loaded when users clicked the reports tab. Teams using heavy visualization libraries like D3.js or Plotly.js saw even larger gains—1+ second improvements because visualization libraries can exceed 500KB.

Edge case: a modal with a chart inside. Load both asynchronously:

typescript
const DetailedReportModal = defineAsyncComponent({
  loader: () => import('./modals/DetailedReportModal.vue'),
  loadingComponent: LoadingOverlay,
  delay: 100
})

// Inside DetailedReportModal.vue
const AdvancedChart = defineAsyncComponent({
  loader: () => import('./AdvancedChart.vue'),
  delay: 50
})

Users don't pay the cost of either component until they open the modal. If they close it without viewing the chart, the chart library never downloads.

Common Mistakes

Teams often stumble in predictable ways when adopting async components:

  • Over-splitting small components: Async components introduce network latency (typically 50–200ms on 4G). Don't async-load a 2KB button wrapper or a simple text card. Use async for components exceeding 30–50KB or those containing heavy dependencies like chart libraries. A common mistake: async-loading a component that's only 8KB. The network overhead outweighs the savings.
  • Forgetting error boundaries: If you define an async component without an errorComponent, network failures crash silently or show console errors while users see nothing. Always provide errorComponent. Production systems without error boundaries have experienced silent component failures where users see blank spaces and assume bugs.
  • Setting delay too high: A 500ms delay to avoid showing spinners on fast connections sounds good until a user on 4G experiences a 2-second blank screen where they expect content. Keep delays under 300ms, ideally 100–200ms. The tradeoff: a few extra spinners on fast connections versus ensuring slow connections don't stall.
  • Ignoring timeout: Omitting a timeout means a slow CDN or failed network can hang your component indefinitely. Set reasonable timeouts (5–10 seconds) based on your infrastructure and user expectations. In one SaaS platform, a missing timeout caused a 30-second hang on a flaky CDN, blocking users from any interaction.
  • Async-loading above the fold: Never async-load critical, visible-on-load components. Users will see layout shift or blank space. Async-load only deferred content below the fold, modals, or interaction-triggered components. Loading the hero section of a landing page asynchronously is a common mistake that tanks first contentful paint.
  • Not measuring actual component size: Teams assume components are heavy without checking. A component might import a 200KB library but only use 5KB of it. Check your bundle with vite-plugin-visualizer or Webpack Bundle Analyzer before async-loading.
  • Suspense boundaries too broad: Wrapping all async components in one Suspense means one slow component delays all others. Use granular Suspense boundaries per section so fast charts render while slow ones load.

Performance Considerations and Best Practices

When deploying async components, consider chunk size, network conditions, and caching:

Chunk size: Keep individual async chunks under 100KB (gzipped). If a component chunk exceeds 200KB, consider splitting it further or evaluating whether async-loading is worth the complexity.

Preloading hints: For components users will likely need, use <link rel="modulepreload"> to hint the browser to prefetch chunks during idle time. On a product page, preload the reviews modal component:

html
<link rel="modulepreload" href="/chunks/ReviewsModal.abc123.js">

Caching strategy: Set long cache headers on chunk files so returning users hit browser cache instead of the network. Nuxt handles this automatically with content-hashed filenames.

Network throttling testing: Always test on 4G and slow 3G. Async components that feel instant on desktop can feel sluggish on mobile networks. Use DevTools throttling or services like WebPageTest to simulate real conditions.

Key Takeaways

  • defineAsyncComponent with loader, loadingComponent, and errorComponent provides production-grade control over lazy-loaded chunks.
  • Set reasonable delay and timeout values—typically delay: 100–200ms and timeout: 5–10s—to balance perceived performance with network reality.
  • Pair async components with Nuxt route-level splitting to create a granular code-splitting strategy where critical content arrives first.
  • Modals and chart libraries are ideal candidates because users often skip them entirely, making async loading a clear performance win.
  • Avoid async-loading critical above-the-fold content; prioritize deferred, interactive, or heavy components instead. Measure component sizes before deciding to async-load.
  • Always provide error and loading fallbacks to maintain a polished user experience under real-world conditions, including network failures and slow connections.
  • Use granular Suspense boundaries per section rather than one large boundary to prevent one slow component from delaying others.
  • Test on realistic networks (4G, slow 3G) to ensure the perceived performance improvement is real for your actual user base.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles