Suggest an editImprove this articleRefine the answer for “async components and suspense in Vue.js”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Async components** in Vue use `defineAsyncComponent` to split JS bundles, loading component code only when rendered. `<Suspense>` wraps async children and shows a `#fallback` slot while their promises are pending. ```vue <Suspense> <AsyncChart /> <!-- loads on demand --> <template #fallback>Loading...</template> </Suspense> ``` **Key:** async components reduce bundle size; Suspense handles the loading UI.Shown above the full answer for quick recall.Answer (EN)Image**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 and `await` in `<script setup>`, shows `#fallback` until 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 ```vue <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: ```vue <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. ```vue <!-- 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> ``` ```vue <!-- 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: ```vue <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`. ```vue <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: ```vue <!-- 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 the `RouterView` slot pattern shown above - Large admin panels: heavy data tables loaded with `delay: 300` to avoid spinner flash on fast connections - Quasar Framework: `QPage` integrates 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 ```vue <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 ```vue <!-- 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> ``` ```vue <!-- 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 ```vue <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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.