Suspense

Suspense renders a fallback while its default slot resolves: two slots, a timeout, three events, and no error handling.

Syntax<Suspense :timeout="0"><template #default>…</template><template #fallback>…</template></Suspense>

<Suspense> is a built-in component that renders fallback content while the async dependencies inside its default slot resolve. The waiting lives in one place instead of in every child that fetches.

The two slots

It takes #default and #fallback. #default is shorthand for v-slot:default; they are the same thing. Both slots allow only one immediate child node, so wrap several elements in one. The node in the default slot is shown if possible; otherwise the fallback is.

What it waits for

Two kinds of dependency: a component with an async setup() hook, which includes <script setup> using a top-level await, and an async component created with defineAsyncComponent. Nothing else suspends — a fetch started in onMounted is invisible to it.

Props and events

The timeout prop, in milliseconds, is how long new default content may take before the fallback is shown again; 0 shows the fallback immediately when the default content is replaced. It emits pending on entering a pending state, resolve when the default slot has finished resolving, and fallback when the fallback is shown.

Two limits

In Vue 3.5 it is still experimental: not guaranteed to reach stable status, and the API may change. And it has no error handling of its own. If the async setup rejects, capture it with onErrorCaptured in the component that renders the <Suspense>; returning false from that hook stops the error propagating further.

Example

Example · vue
<script setup lang="ts">
import { defineAsyncComponent, onErrorCaptured, ref } from 'vue'

const UserProfile = defineAsyncComponent(() => import('./UserProfile.vue'))
const failure = ref<Error | null>(null)

onErrorCaptured((err: unknown) => {
  failure.value = err instanceof Error ? err : new Error(String(err))
  return false
})

function retry(): void {
  failure.value = null
}
</script>

<template>
  <div v-if="failure">
    <p>Could not load the profile: {{ failure.message }}</p>
    <button type="button" @click="retry">Try again</button>
  </div>

  <Suspense v-else :timeout="0">
    <template #default>
      <user-profile :user-id="1" />
    </template>
    <template #fallback>
      <p>Loading the profile</p>
    </template>
  </Suspense>
</template>

<style scoped>
button {
  cursor: pointer;
}
</style>

When to use it

  • Show one loading state for a route whose panels each load their own data.
  • Skip the pending branch in a child component that only ever loads once.
  • Keep the previous screen visible for a moment on navigation with a timeout instead of flashing a spinner.
  • Catch a failed async setup in the parent and offer a retry.

More examples

The child that suspends

A top-level await in <script setup> compiles the component to one with an async setup, which is what makes it an async dependency. There is no pending ref and no v-if chain: by the time the template renders, user is a plain value, because the parent's Suspense held the fallback until the promise resolved. The cost is that this component cannot render at all outside a Suspense boundary, and that the thrown error leaves here and lands in the parent's onErrorCaptured rather than being shown in place.

Example · vue
<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
}

const props = defineProps<{ userId: number }>()

const res = await fetch(`/api/users/${props.userId}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const user = (await res.json()) as User
</script>

<template>
  <section>
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
  </section>
</template>

<style scoped>
section {
  display: grid;
  gap: 0.25rem;
}
</style>

Following the three events

Switching the range gives the async child a new :key, so Vue creates a new instance and the Suspense enters a pending state again. With timeout set to 150 the old report stays on screen for that long, and only a slower request swaps in the fallback — which is why the fallback event is separate from pending. The two fallback slots here are written differently on purpose: #default and v-slot:fallback are the same directive in short and long form. The screen shows a button, a phase line that moves from pending to ready, and the report below.

Example · vue
<script setup lang="ts">
import { ref } from 'vue'
import AsyncReport from './AsyncReport.vue'

type Phase = 'idle' | 'pending' | 'fallback' | 'ready'

const phase = ref<Phase>('idle')
const range = ref<'week' | 'month'>('week')
</script>

<template>
  <button type="button" @click="range = range === 'week' ? 'month' : 'week'">
    Showing {{ range }}
  </button>
  <p>Phase: {{ phase }}</p>

  <Suspense
    :timeout="150"
    @pending="phase = 'pending'"
    @fallback="phase = 'fallback'"
    @resolve="phase = 'ready'"
  >
    <template #default>
      <async-report :key="range" :range="range" />
    </template>
    <template v-slot:fallback>
      <p>Preparing the report</p>
    </template>
  </Suspense>
</template>

<style scoped>
p {
  margin: 0.25rem 0;
}
</style>

Which loading state belongs where

Written as code only because the decision is mechanical. Suspense waits once, before the component exists, so anything that refetches after mount — a changing filter, a retry button, a polled endpoint — keeps its own loading state instead. When a single lazily loaded component wants its own spinner and its own error message, defineAsyncComponent takes loadingComponent, errorComponent, delay and timeout and needs no Suspense at all. Reach for Suspense when a route or a panel has one up-front load and one place to show the waiting.

Example · ts
// src/lib/loading-strategy.ts
export type Strategy = 'suspense' | 'in-component' | 'async-component-options'

export function chooseStrategy(input: {
  usesTopLevelAwait: boolean
  refetchesAfterMount: boolean
  needsOwnErrorMessage: boolean
}): Strategy {
  if (input.refetchesAfterMount) return 'in-component'
  if (input.needsOwnErrorMessage) return 'async-component-options'
  return input.usesTopLevelAwait ? 'suspense' : 'in-component'
}

// chooseStrategy({ usesTopLevelAwait: true, refetchesAfterMount: false,
//   needsOwnErrorMessage: false })
// Output: suspense

// chooseStrategy({ usesTopLevelAwait: true, refetchesAfterMount: true,
//   needsOwnErrorMessage: false })
// Output: in-component

// chooseStrategy({ usesTopLevelAwait: false, refetchesAfterMount: false,
//   needsOwnErrorMessage: true })
// Output: async-component-options

Discussion

  • Be the first to comment on this lesson.