Async components defer downloading and evaluating a component until it is needed. Use defineAsyncComponent for a heavy, optional interface region such as an editor or report, while route-level dynamic imports split entire screens. The learner should be able to choose a boundary, show loading and failure states, and avoid splitting tiny components that only add requests.
Async components let you load components lazily - only when they're needed. This reduces the initial bundle size and improves page load performance. Vue's defineAsyncComponent() wraps a dynamic import and handles loading/error states.
<template>
<div>
<!-- Async component - loaded only when rendered -->
<HeavyChart v-if="showChart" />
<button @click="showChart = !showChart">Toggle Chart</button>
<!-- With loading and error states -->
<AdminPanel v-if="isAdmin" />
</div>
</template>
<script setup>
import { ref, defineAsyncComponent } from 'vue'
// Simple async component - just a dynamic import
const HeavyChart = defineAsyncComponent(() =>
import('./HeavyChart.vue')
)
// With options - loading state, error state, timeout
const AdminPanel = defineAsyncComponent({
// The loader function
loader: () => import('./AdminPanel.vue'),
// Component to show while loading
loadingComponent: {
template: '<div class="loading-spinner">Loading...</div>'
},
// Delay before showing loading component (ms)
delay: 200,
// Component to show if loading fails
errorComponent: {
template: '<div class="error">Failed to load component</div>'
},
// Timeout - show error if loading takes too long
timeout: 5000,
// Called when loading fails
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
retry() // retry up to 3 times
} else {
fail()
}
}
})
const showChart = ref(false)
const isAdmin = ref(true)
</script>
<!-- Suspense - handle async setup() in child components -->
<template>
<div>
<!-- Suspense wraps async components -->
<Suspense>
<!-- Default slot: shown when ready -->
<template #default>
<AsyncUserProfile :userId="userId" />
</template>
<!-- Fallback slot: shown while loading -->
<template #fallback>
<div class="skeleton">
<div class="skeleton-avatar"></div>
<div class="skeleton-text"></div>
<div class="skeleton-text short"></div>
</div>
</template>
</Suspense>
<!-- Combine with Transition for smooth loading -->
<Suspense @pending="onPending" @resolve="onResolve" @fallback="onFallback">
<template #default>
<Transition name="fade" mode="out-in">
<AsyncDashboard :key="currentPage" />
</Transition>
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</div>
</template>
<script setup>
import { ref, defineAsyncComponent } from 'vue'
const AsyncUserProfile = defineAsyncComponent(() => import('./UserProfile.vue'))
const AsyncDashboard = defineAsyncComponent(() => import('./Dashboard.vue'))
const userId = ref(1)
const currentPage = ref('home')
function onPending() { console.log('Loading started') }
function onResolve() { console.log('Loading complete') }
function onFallback() { console.log('Showing fallback') }
</script>
<!-- AsyncUserProfile.vue - uses async setup() -->
<!-- <script setup> -->
<!-- const props = defineProps({ userId: Number }) -->
<!-- // await in setup() - Suspense waits for this -->
<!-- const user = await fetch(`/api/users/${props.userId}`).then(r => r.json()) -->
<!-- </script> -->
A loading delay prevents a spinner from flashing on fast connections, while a timeout and error component give slow or failed loads a recoverable state. Retrying should create a fresh import attempt only when the failure can be transient. Keep essential navigation and the first useful screen in the initial path.
Suspense can coordinate async setup dependencies in a subtree, but it does not replace route and request error handling. Verify chunks through the production build and browser network panel; development timing is not representative of cached, compressed deployment assets.
defineAsyncComponent accepts a loader that returns an import promise. Vue delays loading until the component is rendered, caches the resolved definition, and forwards props and slots to it. The options form adds loading, error, delay, timeout, and retry behavior for slow or unreliable chunks.
Keep loading and error components small because they are part of the fallback path. A timeout changes the displayed state but does not guarantee the underlying network request is cancelled. Use onError to retry only failures likely to be transient and cap the number of attempts.
import { defineAsyncComponent } from 'vue'
import LoadingPanel from './LoadingPanel.vue'
import LoadError from './LoadError.vue'
export const ReportsPanel = defineAsyncComponent({
loader: () => import('./ReportsPanel.vue'),
loadingComponent: LoadingPanel,
errorComponent: LoadError,
delay: 150,
timeout: 10_000,
onError(error, retry, fail, attempts) {
const transient = /fetch|network|loading chunk/i.test(error.message)
if (transient && attempts <= 2) retry()
else fail()
},
})
The boundary provides stable loading, timeout, error, and bounded retry behavior around the dynamic import.
For route records, assign the dynamic import function directly to component so the router loads that chunk only when navigation needs it. Do not wrap route components in defineAsyncComponent unless its loading behavior is specifically required. Group chunks only when the routes are commonly used together; one oversized shared chunk defeats lazy loading.
Suspense coordinates async setup and async components below one boundary. Provide meaningful fallback UI, handle rejected work, and remember that Suspense does not replace route-level error handling or a retry decision. Prefetch critical chunks only when the bandwidth cost is justified by likely navigation.
Explore 500+ free tutorials across 20+ languages and frameworks.