the right way in Vue 3
Vue 3 introduced three features that are rarely explained together, yet complement each other perfectly: Teleport for correct DOM placement, Suspense for declarative loading states and Async Components for targeted code splitting. Anyone who understands all three and combines them sensibly builds Vue applications with better UX, smaller bundles and a cleaner component architecture.
Table of Contents
- 1. Overview: Teleport, Suspense and Async Components
- 2. Teleport: decoupling DOM placement from the component tree
- 3. Teleport for modals, toasts and overlays
- 4. Async Components with defineAsyncComponent
- 5. Loading and error states in Async Components
- 6. Suspense: declarative loading states in the template
- 7. Combining Suspense with Async Components
- 8. When each feature makes sense
- 9. Comparison: use cases at a glance
- 10. Summary
- 11. FAQ
1. Overview: Teleport, Suspense and Async Components
Vue Teleport, Vue Suspense and Async Components address three different problems in the Vue component model. Teleport solves the DOM placement problem: a component that is deeply nested in the component tree needs its DOM output to render at a different location in the document, typically for modals and overlays that must render directly under body to avoid CSS stacking context issues. The problem is old, but the solution in Vue 3 is elegant and declarative.
Vue Suspense solves the loading state problem for async-capable components. Instead of managing v-if="isLoading" individually in every component, you declare a fallback slot in the template that is shown until all asynchronous children have satisfied their data requirements. Async Components complement Suspense with code splitting: the component and its JavaScript are only loaded from the server once the component is actually about to render. That reduces the initial bundle size and speeds up First Meaningful Paint.
The combination of all three: a route lazy-loads via the router. The route's main component uses Vue Suspense as a wrapper. Inside the Suspense wrapper sit Async Components that load their data in setup() via await. A modal composable elsewhere on the page uses Vue Teleport to attach the modal to body instead of into the route's nested DOM structure. This architecture is maintainable, performant and avoids the typical CSS problems with overlays in deeply nested components.
2. Teleport: decoupling DOM placement from the component tree
Vue Teleport is a built-in wrapper that renders its slot content at a different DOM node than the one where the component sits in the Vue tree. The to attribute accepts a CSS selector or a DOM element object. The reactive logic of the teleported component, its props, emits and reactivity, remains fully intact inside the Vue component tree. Only the DOM position changes. That is the crucial difference: Vue Teleport is not a true decoupling of the component, only a repositioning of its DOM output.
The classic problem without Vue Teleport: a modal is defined inside a deeply nested component. The CSS overflow: hidden or transform of an ancestor element creates a new stacking context. The modal, even though it has position: fixed, is confined to this stacking context and does not appear correctly above all other elements. With Vue Teleport, the modal DOM is rendered directly under body, outside every problematic stacking context, while the Vue logic remains in the original component.
<!-- ConfirmModal.vue, uses Vue Teleport to render outside component tree -->
<template>
<!-- Teleport renders DOM to body, but component stays in Vue tree -->
<Teleport to="body">
<Transition name="modal-fade">
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
:aria-labelledby="`modal-title-${uid}`"
@keydown.escape="$emit('close')"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50" @click="$emit('close')" />
<!-- Panel, outside any parent overflow:hidden or transform context -->
<div class="relative z-10 bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full mx-4">
<h2 :id="`modal-title-${uid}`" class="text-xl font-bold mb-4">{{ title }}</h2>
<slot />
<div class="flex gap-3 mt-6 justify-end">
<button @click="$emit('close')">Cancel</button>
<button @click="$emit('confirm')">Confirm</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { getCurrentInstance } from 'vue'
// Unique ID per instance, accessible for aria-labelledby
const uid = getCurrentInstance()?.uid
defineProps<{ isOpen: boolean; title: string }>()
defineEmits<{ close: []; confirm: [] }>()
</script>
3. Teleport for modals, toasts and overlays
The most common use cases for Vue Teleport are modals, toast notifications and dropdown overlays. With toasts, the problem is that they are triggered from any depth of the component tree but always need to appear at the same location in the document, usually top right or bottom right, fixed above the entire page content. The pattern: a global toast container that is rendered directly under body via Vue Teleport, and a Pinia store or composable-based toast API that any component can call.
When using Vue Teleport for dropdowns, for example in table rows or cards that sit inside scrollable containers, the same principle applies: the dropdown panel is teleported to body, and positioning happens via JavaScript with getBoundingClientRect() of the trigger element. That avoids the overflow: auto clipping of the scrollable container. The additional effort is manually updating the position on scroll and resize events. @floating-ui/vue takes this work off your hands and is the recommended solution for complex dropdown positioning with Vue Teleport.
4. Async Components with defineAsyncComponent
Async Components in Vue 3 are created with defineAsyncComponent(). The function takes a factory function that returns a promise, typically a dynamic import statement. Vite and Webpack detect dynamic imports and automatically create separate chunks for the affected components. These chunks are only loaded once the component is actually about to render. For large components such as editors, chart libraries or complex forms, that is a significant loading-time advantage for every user who never sees these parts of the application.
Using Async Components with defineAsyncComponent is particularly worthwhile in combination with Vue Router. Router-level lazy loading is standard for every route, but within a route there are often further heavy components that are not needed immediately: a heavy rich-text editor that only appears once "Edit" is clicked, or a data table component that only shows up after the initial page data has loaded. This is where Async Components come in, more fine-grained than router-level splitting, but without manual bundle management.
5. Loading and error states in Async Components
Async Components via defineAsyncComponent() can be configured with loading and error components that are displayed while the component is loading or when the loading process fails. The options object supports loadingComponent, errorComponent, delay (milliseconds before the loading component appears) and timeout (after which the error component is shown). The delay parameter prevents the "flash of loading state" on fast networks: if the component loads in under 200ms, the loading component does not appear at all.
The interplay of Async Components with errorComponent and timeout is especially important for mobile users with an unstable connection. Instead of a stuck, empty spot in the UI, an error message with a retry option appears after the configured timeout. The errorComponent receives an error prop with the caught error and can derive specific error messages from it, such as "No connection", "Module not found" or a generic fallback message. For SSR, Async Components need to be configured specifically, since the server expects synchronous rendering.
// defineAsyncComponent with loading, error and timeout config
import { defineAsyncComponent } from 'vue'
// Simple form: just a dynamic import, Vite creates a separate chunk
const HeavyEditor = defineAsyncComponent(
() => import('./components/RichTextEditor.vue')
)
// Full config: with loading/error components and timeout
const DataTable = defineAsyncComponent({
loader: () => import('./components/DataTable.vue'),
// Show skeleton after 200ms, avoids flash on fast connections
loadingComponent: () => import('./components/SkeletonTable.vue'),
delay: 200,
// Show error component after 8s timeout
errorComponent: () => import('./components/ErrorState.vue'),
timeout: 8000,
// Called when loader rejects, return true to retry
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
retry() // auto-retry up to 3 times
} else {
fail() // give up, show errorComponent
}
},
})
// Usage in template, acts like a normal component
// <DataTable :rows="rows" @sort="onSort" />
6. Suspense: declarative loading states in the template
Vue Suspense is a built-in wrapper that coordinates the loading of asynchronous child components. A component counts as asynchronous for Vue Suspense if its setup() hook returns a promise, either through async setup() or through a top-level await in <script setup>. Vue Suspense shows the #fallback slot until all asynchronous children have resolved, then switches to the #default slot. That is declarative loading state management without a single v-if="isLoading" in the child components themselves.
The important difference from manual loading states: Vue Suspense coordinates multiple async child components at once. When three child components load data in parallel, Vue Suspense shows the fallback until all three are done, not until the first one finishes, then the second one partially, then the third one. That prevents the "popcorn loading" pattern, where elements pop in one after another with an offset and the page visually jumps. The fallback slot can show a complete skeleton layout that matches the final view and minimizes layout shift.
7. Combining Suspense with Async Components
The most powerful combination is Vue Suspense with Async Components and top-level await in <script setup>. An async component only downloads its JavaScript when needed. Once the module is loaded, <script setup> executes the top-level await and fetches the required data. Vue Suspense coordinates both asynchronous phases, module download and data fetch, behind a single fallback slot. That is a complete code-splitting-and-data-loading pattern without a manual loading state in any child component.
For server-side rendering, Vue Suspense is the recommended mechanism in Nuxt 3 (available there automatically through the useFetch and useAsyncData integration). For client-only usage with Vite, note that Vue Suspense is still marked as experimental (as of Vue 3.x), which means the API is stable but may not yet be fully documented for every usage pattern. In production it works reliably for the patterns described here. Error handling happens through the @resolve, @pending and @fallback events of Vue Suspense or through a higher-level error boundary mechanism.
<!-- ProductDetail.vue, top-level await: component is "async" for Suspense -->
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useProductStore } from '@/stores/product'
const route = useRoute()
const store = useProductStore()
// Top-level await, makes this component work with <Suspense>
// No isLoading flag needed: Suspense handles the waiting state
const product = await store.fetchProduct(route.params.id as string)
</script>
<template>
<!-- No v-if="isLoading" needed, Suspense handles it in the parent -->
<div>
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
</div>
</template>
<!-- In the parent route view: -->
<!-- <Suspense>
<template #default>
<ProductDetail /> <- async component, fetches in setup()
</template>
<template #fallback>
<ProductDetailSkeleton /> <- shown while loading
</template>
</Suspense> -->
8. When each feature makes sense
Vue Teleport makes sense whenever DOM elements need to render outside their natural component-tree context to avoid CSS stacking context issues. That is almost always the case with modals, global toast notifications, dropdown menus in tables and tooltips inside overflow: hidden containers. If none of these situations apply, Vue Teleport is unnecessary and only adds complexity.
Async Components make sense for components over 50 KB compressed that are not visible on the initial page load. Rich-text editors, chart libraries, complex forms with many fields and validation logic, heavy data tables. For small components that are immediately visible, Async Components create more overhead (an extra HTTP request) than they save. Vue Suspense is most valuable in combination with top-level await in <script setup> and when multiple children load at the same time and their loading state needs to be coordinated.
9. Comparison: use cases at a glance
The three features solve clearly different problems and should not be interchanged. This table shows which feature is the right tool for which situation.
| Problem | Wrong tool | Right feature | Why |
|---|---|---|---|
| Render modal under body | Manual DOM append | Vue Teleport | Reactivity stays in the Vue tree |
| Lazy-load a heavy component | Everything in the main bundle | defineAsyncComponent | Separate chunk, loaded only when needed |
| Coordinate loading state | v-if="isLoading" in every child | Vue Suspense | Unified fallback for all children |
| Dropdown out of overflow:hidden | CSS overflow:visible on the container | Teleport + @floating-ui/vue | Ported panel, correct positioning |
| Load chart only on tab switch | Chart always in the DOM, v-show | defineAsyncComponent + v-if | Chunk only when the tab is active |
A common mistake is the premature use of Vue Suspense for components that already load quickly and whose data is cached in the store. Vue Suspense with a skeleton fallback for a component that always answers from the cache after the first load creates a visible skeleton flash that looks worse than no loading state at all. The solution: only use Vue Suspense when asynchronous waiting is truly unavoidable, and design the store cache so that cached data is returned synchronously.
Mironsoft
Vue 3 performance and architecture
Need a Vue 3 performance architecture for your project?
We optimize Vue 3 applications with targeted code splitting, Teleport-based architecture for overlays and Suspense-driven loading strategies, delivering measurably smaller bundle sizes and better Core Web Vitals.
Bundle analysis
Identifying heavy components and refactoring to defineAsyncComponent
Modal architecture
Teleport-based modal and toast systems without CSS stacking issues
Loading strategy
Suspense integration with skeleton layouts for better perceived performance
10. Summary
Vue Teleport, Vue Suspense and Async Components are complementary tools for three specific problems. Teleport belongs in the standard toolkit for every modal, every toast component and every dropdown inside an overflow: hidden container. defineAsyncComponent should be applied to every component over 50 KB that is not visible on the first page load. Vue Suspense declaratively coordinates the loading states of multiple async children and replaces the manual v-if="isLoading" pattern in the child components.
Combining all three in a route architecture gives you: router-level lazy loading for routes, Async Components for heavy sub-components within a route, Vue Suspense as the coordinating wrapper with a skeleton fallback, and Vue Teleport for every overlay element. Each feature has its clear scope of use. The real strength lies in only applying them when the specific problem they were built for is actually present.
Teleport, Suspense and Async Components, the essentials at a glance
Vue Teleport
to="body" for modals, toasts and dropdowns inside overflow:hidden containers. Reactivity stays in the Vue tree. Positioning with @floating-ui/vue.
Async Components
defineAsyncComponent() for components over 50 KB that are not immediately visible. loadingComponent and delay to avoid flashes. onError for retry logic.
Vue Suspense
Coordinates the loading states of multiple async children. Fallback slot shows a skeleton. Combined with top-level await in script setup, no manual isLoading state.
Combination
Router lazy + Async Components + Suspense + Teleport: smaller bundles, better UX, clean overlay architecture without CSS stacking context problems.