async components and suspense in Vue.js
Async components and Suspense are two separate Vue 3 tools that solve different problems. defineAsyncComponent splits your JS bundle and loads component code on demand. <Suspense> manages what users see while that loading happens.
Theory
TL;DR
- Async components use dynamic
import()to create separate JS chunks, cutting initial payload by 50-90% in large apps <Suspense>tracks pending promises from async components andawaitin<script setup>, shows#fallbackuntil all resolve- Main split: async = code splitting (network); Suspense = loading coordination (UI)
- Use async for components over 10KB not needed on first render; add Suspense when users wait over 500ms
- Suspense waits for the slowest child. No partial content reveal inside a single boundary
Quick example
<script setup>
import { defineAsyncComponent } from 'vue'
// Vite/webpack splits Chart.vue into a separate JS chunk
const AsyncChart = defineAsyncComponent(() => import('./Chart.vue'))
</script>
<template>
<Suspense>
<template #default>
<AsyncChart /> <!-- chunk fetches when this mounts -->
</template>
<template #fallback>
<div>Loading chart...</div> <!-- renders immediately -->
</template>
</Suspense>
</template>"Loading chart..." appears right away. Chart.vue fetches in the background and swaps in once ready. No blank screen.
Key difference
defineAsyncComponent is about the network. It tells Vite or webpack to put a component into its own JS file, then fetches that file via dynamic import() when the component first renders. Suspense knows nothing about code splitting. It scans its child tree for pending promises, either from async components or from await calls in <script setup>, and holds the #fallback slot visible until every promise settles. One reduces your bundle. The other prevents blank screens.
defineAsyncComponent with options
The simple form wraps a single import(). The options form gives you control over loading and error states:
<script setup>
import { defineAsyncComponent } from 'vue'
const AsyncUsersTable = defineAsyncComponent({
loader: () => import('./UsersTable.vue'),
loadingComponent: LoadingSpinner, // per-component fallback
errorComponent: ErrorDisplay, // shown if chunk fails to load
delay: 300, // show loadingComponent only after 300ms
timeout: 8000 // show errorComponent if not done in 8 seconds
})
</script>The delay option solves a real UX problem. On fast connections the chunk loads in under 300ms, so users never see the spinner. Only slower connections trigger it. Skip delay and you get a flash of loading state on every page load, even for users with fast internet. Most teams discover this the hard way in production.
How Suspense tracks async children
When Suspense mounts, Vue registers pending promises from every async child, including components loaded via defineAsyncComponent and any await in <script setup>. The fallback slot renders immediately. Once all registered promises resolve, Vue patches the DOM with actual content.
If a component uses await in setup without a Suspense parent, Vue logs a warning in dev mode. That warning is not noise. The component hangs in an unresolved state with nothing to show.
<!-- UserProfile.vue: async because of the awaits -->
<script setup>
const response = await fetch('/api/user/1')
const user = await response.json()
</script>
<template>
<div>{{ user.name }}</div>
</template><!-- Parent.vue: Suspense catches UserProfile's pending promises -->
<template>
<Suspense>
<UserProfile />
<template #fallback>
<p>Loading user...</p>
</template>
</Suspense>
</template>Suspense with Vue Router
Route-level lazy loading is the most common production pattern. Each route loads as a separate chunk, and Suspense prevents blank screens during navigation:
<template>
<RouterView v-slot="{ Component }">
<Suspense>
<template #default>
<component :is="Component" />
</template>
<template #fallback>
<PageLoading />
</template>
</Suspense>
</RouterView>
</template>Error handling
Suspense does not catch errors on its own. Two options: onErrorCaptured in the wrapper component, or errorComponent in defineAsyncComponent.
<script setup>
import { onErrorCaptured, ref } from 'vue'
const error = ref(null)
onErrorCaptured((err) => {
error.value = err
return false // stop the error from bubbling further
})
</script>
<template>
<div v-if="error">{{ error.message }}</div>
<Suspense v-else>
<AsyncComponent />
<template #fallback><Loading /></template>
</Suspense>
</template>Always define errorComponent in defineAsyncComponent. Deployments that change chunk hash filenames cause 404s for users who still have the old HTML cached. Without an error state, they get a blank page with no feedback.
Common mistakes
Putting v-if directly on <Suspense> causes it to unmount and restart all tracked promises every time the condition flips:
<!-- Wrong: Suspense unmounts on false, restarts promises on true -->
<Suspense v-if="isReady">
<AsyncComponent />
</Suspense>
<!-- Correct: v-if goes on the inner content -->
<Suspense>
<AsyncComponent v-if="isReady" />
<template #fallback><Loading /></template>
</Suspense>No timeout on slow networks. The default is no timeout at all. On 3G, users can stare at a spinner indefinitely. Set timeout in defineAsyncComponent.
Wrapping sync components in Suspense. Suspense always renders #fallback first, even for instant children. If nothing in the tree is async, you pay overhead for nothing. Test before adding it.
Missing errorComponent. Unhandled chunk failures produce silent blank screens. Always provide an error state with a retry path.
Cascading nested Suspense without a clear plan. Inner boundaries resolve independently from outer ones. Stack too many and users see a sequence of loading states that looks broken. One Suspense per route is usually the right level.
Real-world usage
- Nuxt 3:
<NuxtPage>auto-wraps async route layouts in Suspense for island architecture - Vue Router apps: route components as
() => import('./View.vue')with theRouterViewslot pattern shown above - Large admin panels: heavy data tables loaded with
delay: 300to avoid spinner flash on fast connections - Quasar Framework:
QPageintegrates Suspense for async panel content
Follow-up questions
Q: How does Suspense handle multiple async children?
A: It collects all pending promises and holds the fallback until every one resolves. The fastest component waits for the slowest. There is no partial reveal inside a single boundary.
Q: What is the difference between loadingComponent in defineAsyncComponent and the Suspense #fallback slot?
A: loadingComponent is per-component and respects delay and timeout options. The #fallback slot covers the entire boundary and has no timing options. Use loadingComponent when each component needs its own loading behavior; use #fallback for a single loading state across a group.
Q: What happens if an async component throws after Suspense has already resolved?
A: The error bubbles to the nearest onErrorCaptured or to the errorComponent defined in defineAsyncComponent. Suspense does not re-enter fallback mode after resolving.
Q: How do async components behave with SSR?
A: Dynamic imports are skipped on the server. The component renders client-side, with Suspense showing its fallback during hydration.
Q: In Vue 3.4+, what happens when an async setup spans a teleport boundary?
A: The setup promise suspends the teleport source. The Suspense boundary must wrap the sender component, not the teleport target. Wrap the target and the boundary cannot see the suspended vnode tree, so the fallback never shows.
Examples
Basic: lazy modal on user action
<script setup>
import { defineAsyncComponent, ref } from 'vue'
// HeavyModal chunk loads only when user opens it
const AsyncModal = defineAsyncComponent({
loader: () => import('./HeavyModal.vue'),
errorComponent: ErrorDisplay,
timeout: 5000
})
const showModal = ref(false)
</script>
<template>
<button @click="showModal = true">Open settings</button>
<Suspense>
<template #default>
<AsyncModal v-if="showModal" @close="showModal = false" />
</template>
<template #fallback>
<div v-if="showModal">Loading settings...</div>
</template>
</Suspense>
</template>The modal chunk stays out of the initial bundle. The first click triggers the fetch. Suspense shows a loading message during that fetch, then renders the modal. No blank screen, no upfront bundle cost.
Intermediate: dashboard panel with async data fetch
<!-- UserDashboard.vue: async because of await in setup -->
<script setup>
const res = await fetch('/api/dashboard')
const stats = await res.json()
</script>
<template>
<div class="dashboard">
<h2>Total orders: {{ stats.orders }}</h2>
<p>Revenue: {{ stats.revenue }}</p>
</div>
</template><!-- App.vue: Suspense catches the async setup from UserDashboard -->
<script setup>
import { onErrorCaptured, ref } from 'vue'
const error = ref(null)
onErrorCaptured((err) => { error.value = err; return false })
</script>
<template>
<div v-if="error">Failed to load dashboard: {{ error.message }}</div>
<Suspense v-else>
<template #default>
<UserDashboard />
</template>
<template #fallback>
<DashboardSkeleton />
</template>
</Suspense>
</template><DashboardSkeleton /> shows while the API call resolves. If the request fails, onErrorCaptured intercepts and shows the error message. Users always see feedback, never a blank page.
Advanced: multiple async children loading in parallel
<script setup>
import { defineAsyncComponent } from 'vue'
// Three separate chunks, all fetched in parallel
const AsyncChart = defineAsyncComponent(() => import('./RevenueChart.vue'))
const AsyncTable = defineAsyncComponent(() => import('./OrdersTable.vue'))
const AsyncMap = defineAsyncComponent(() => import('./DeliveryMap.vue'))
</script>
<template>
<Suspense>
<template #default>
<!-- All three load in parallel; fallback holds until the slowest resolves -->
<AsyncChart />
<AsyncTable />
<AsyncMap />
</template>
<template #fallback>
<div>Loading dashboard panels...</div>
</template>
</Suspense>
</template>All three chunks fetch in parallel. The single Suspense boundary waits for all three before swapping out the fallback. If you need each panel to reveal independently as it loads, wrap each in its own <Suspense> instead.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.