Skip to content
Deep diveAdvanced Vue

Async components, Suspense and lazy loading

A dynamic import() is a split point for Vite, defineAsyncComponent() turns it into a component, and <Suspense> coordinates the waiting. Here is how the three fit together, and the one mistake that renders nothing at all.

vuejs.school team19 Jun 20258 min readVue 3.5 · Vite 6

A settings page has a tab with a chart. The chart component pulls in a charting library, and most visitors never open that tab, yet every visitor downloads it with the first page and waits for it. Load it only when the tab renders: wrap a dynamic import() in defineAsyncComponent() and give it a loading and an error state.

<Suspense> goes further and holds a whole section until every async piece in it is ready. It is still marked experimental, but a component with a top-level await needs it: rendered outside a <Suspense>, that component leaves a blank space and throws no error.

How Vite splits a dynamic import into its own file

A static import, import ChartPanel from './ChartPanel.vue', puts the component and everything it imports into the same bundle as the importer. A dynamic import, import('./ChartPanel.vue'), returns a promise and tells the bundler that this file may be loaded later.

Vite, like other bundlers, treats that as a split point. At build time ChartPanel.vue and its dependencies become a separate file in dist/assets/, named after the component with a content hash, such as ChartPanel-Bq3kL2.js. In its place the main bundle keeps only a small stub that fetches that file on demand, and the browser caches it after the first load. In development Vite serves the module directly, so you see the split only in a production build:

npm run build
# dist/assets/index-DkT9x1.js      182 kB
# dist/assets/ChartPanel-Bq3kL2.js  96 kB

Load a component on demand with defineAsyncComponent

defineAsyncComponent() takes a loader, a function returning a promise of a component, and gives back a component you use like any other. In its simplest form it wraps the dynamic import:

pages/Settings.vue
<script setup lang="ts">
import { defineAsyncComponent, ref } from 'vue'

const activeTab = ref<'profile' | 'usage'>('profile')
const ChartPanel = defineAsyncComponent(() => import('../components/ChartPanel.vue'))
</script>

<template>
  <ChartPanel v-if="activeTab === 'usage'" />
</template>

Nothing is fetched until ChartPanel is actually rendered, which here means until activeTab becomes 'usage'. Props and slots pass straight through to the real component once it arrives.

Over a slow connection the user sees nothing where the chart should be, and if the request fails they see nothing forever. The options form fixes both. It replaces the one-line call in the same <script setup>:

pages/Settings.vue
import { defineAsyncComponent } from 'vue'
import ChartSkeleton from '../components/ChartSkeleton.vue'
import LoadFailed from '../components/LoadFailed.vue'

const ChartPanel = defineAsyncComponent({
  loader: () => import('../components/ChartPanel.vue'),
  loadingComponent: ChartSkeleton,
  delay: 200,
  errorComponent: LoadFailed,
  timeout: 10_000,
  onError(error, retry, fail, attempts) {
    if (attempts <= 3) retry()
    else fail()
  },
})
  • loadingComponent renders while the import is in flight, but only after delay milliseconds (200 by default). On a fast connection the chunk arrives sooner, and a skeleton that flashes for 50 ms looks like a glitch.
  • errorComponent renders if the loader's promise rejects, or if timeout is set and that many milliseconds pass first. The default timeout is Infinity, meaning wait forever.
  • onError is called on each failure with a retry function, a fail function and the attempt number, starting at 1. Here the loader runs up to four times before the error component shows.

retry() only calls the loader again, so it cannot fix every failure. The HTML spec has the browser remember a failed module fetch for the rest of the page's life, so in browsers that follow it, a second import() of the same file fails at once (whatwg/html#6768). And after a deploy that deleted the old chunks, a tab opened before it keeps asking for a file that is gone. Vite fires a vite:preloadError event on window when a dynamic import fails, and its docs show reloading the page there for this case.

Lazy-load tabs and keep their state with KeepAlive

Tabs suit lazy loading, and <component :is> renders whichever tab is active. Build a map from tab name to async component, and the template picks one:

pages/Settings.vue
<script setup lang="ts">
import { defineAsyncComponent, ref, type Component } from 'vue'

const tabNames = ['profile', 'usage', 'billing'] as const
type TabName = (typeof tabNames)[number]

const tabs: Record<TabName, Component> = {
  profile: defineAsyncComponent(() => import('../components/ProfileForm.vue')),
  usage: defineAsyncComponent(() => import('../components/ChartPanel.vue')),
  billing: defineAsyncComponent(() => import('../components/BillingTable.vue')),
}

const activeTab = ref<TabName>('profile')
</script>

<template>
  <nav>
    <button v-for="name in tabNames" :key="name" type="button" @click="activeTab = name">{{ name }}</button>
  </nav>
  <KeepAlive>
    <component :is="tabs[activeTab]" />
  </KeepAlive>
</template>

Each tab's chunk is loaded on the first visit to that tab and not before. <KeepAlive> caches the component of a tab you leave instead of destroying it, so a half-filled profile form survives a look at the usage chart. Without it, switching tabs re-creates the component and loses its state. The chunk itself stays cached either way, so only the state is at stake.

Define the map outside the template, as here, so that defineAsyncComponent() runs once per tab rather than on every render.

Wait for async setup with Suspense

So far each async component handled its own waiting. <Suspense> is a built-in component that takes over that job for a whole subtree: it renders a fallback until every async dependency underneath it has resolved, then swaps in the real content in one go. It knows about two kinds of dependency. One is an async component, as above. The other is a component whose setup is itself async, which in <script setup> means a top-level await:

components/UsageSummary.vue
<script setup lang="ts">
const response = await fetch('/api/usage')
const usage: { requests: number } = await response.json()
</script>

<template>
  <p>{{ usage.requests }} requests this month</p>
</template>

Because of the await, Vue treats UsageSummary as an async dependency. A parent imports it, renders it inside a <Suspense> boundary and provides the fallback:

pages/Settings.vue
<script setup lang="ts">
import UsageSummary from '../components/UsageSummary.vue'
</script>

<template>
  <Suspense>
    <UsageSummary />
    <template #fallback>
      <p>Loading usage…</p>
    </template>
  </Suspense>
</template>

The #default slot takes one root node, and #fallback is shown until everything inside that node has resolved.

An async component under a <Suspense> becomes suspensible by default: its own loadingComponent, delay and timeout are ignored, and the boundary's fallback shows while it loads. Its onError and errorComponent still apply, so a failed import replaces the fallback with the error component. Pass suspensible: false in its options to keep all of its own behaviour.

One more rule from the docs catches people: a lazily loaded route in Vue Router does not trigger <Suspense>, even though it is also a dynamic import. The router loads the route's file itself during navigation, before the route renders, so the boundary has nothing to wait for. Async components and async setup inside the route still trigger it.

Fix a component that never renders: await without Suspense

You add a top-level await to a component and render it as usual (the import stays as above):

pages/Settings.vueBreaks
<template>
  <h1>Settings</h1>
  <UsageSummary />
</template>

The heading renders. The summary never does, and no error is thrown. In development Vue warns that the component's setup function returned a promise, but no <Suspense> boundary was found in the parent component tree, and that a component with an async setup() must be nested in a <Suspense> to be rendered. In production there is no warning, just a gap. The fix is the boundary from the previous section:

pages/Settings.vueWorks
<template>
  <h1>Settings</h1>
  <Suspense>
    <UsageSummary />
    <template #fallback><p>Loading usage…</p></template>
  </Suspense>
</template>

If you would rather not have a boundary, start the request in onMounted instead and keep a status ref for the loading and error states. Top-level await is shorter; onMounted keeps the component usable anywhere. Decide per component, and never leave an await in a component that is rendered without a boundary.

Catch a failed async setup with onErrorCaptured

<Suspense> has no error handling of its own. If the fetch() in UsageSummary rejects, the error goes to the parent, and you catch it there with onErrorCaptured() in the <script setup> of pages/Settings.vue:

pages/Settings.vue
import { onErrorCaptured, ref } from 'vue'

const loadError = ref<string | null>(null)

onErrorCaptured((error) => {
  // keep the first error: in development a render error can follow it
  loadError.value ??= error instanceof Error ? error.message : 'Could not load'
  return false // stop it propagating further
})

After the failure the boundary resolves anyway, and UsageSummary renders nothing because its setup never finished. Show the message in place of the boundary: a <p v-if="loadError"> before it and v-else on the <Suspense>.

If you keep the boundary on the page instead, a development build calls the hook a second time with a render error such as Cannot read properties of undefined (reading 'requests'), because the template runs without the values the setup never returned. ??= assigns only while loadError is still null, so you keep the first, real error.

Control what Suspense shows when its content changes

The boundary emits three events: pending when it starts waiting, fallback when the fallback is shown and resolve when the new content is ready. After the first resolve, it goes back to pending only if the root node of the default slot is replaced. The old content then stays on screen while the new content loads; with the timeout prop set, the fallback replaces it after that many milliseconds (0 means at once). A new async component deeper in the tree does not send the boundary back to pending at all; it renders an empty node until it loads.

Since Vue 3.3 a boundary can be nested inside another. The docs show the case it fixes: two async components rendered with <component :is>, one inside the other. Swap the inner one and it renders an empty node until the new one loads. Wrap it in <Suspense suspensible> and the old inner content stays until the new one is ready. The suspensible prop hands the inner boundary's waiting and events to the outer one instead of giving it a fallback of its own. Reach for it only when you see that empty flash.

When to use Suspense while it is experimental

The Vue docs mark <Suspense> as experimental: it "is not guaranteed to reach stable status and the API may change before it does". The development build repeats this in the console the first time a boundary renders: <Suspense> is an experimental feature and its API will likely change. It has carried that label since Vue 3.0, and the main addition since then is the suspensible prop from 3.3. Nuxt wraps every page in a <Suspense> so that a page can await its data before it shows, so it is well exercised in practice.

Use defineAsyncComponent() freely: it is stable, and it is where most of the benefit lives. Add <Suspense> when several async pieces should appear together and you are prepared to adjust a boundary or two if the API shifts. Keep boundaries few.

Check it stuck3 questions from the Dynamic and async components quiz.
Practice now
Was this helpful?

Keep reading