.agents/skills/vue-best-practices/references/component-suspense.md
Impact: MEDIUM - <Suspense> coordinates async dependencies (async components or async setup) and renders a fallback while they resolve. Misconfiguration leads to missing loading states, empty renders, or subtle UX bugs.
timeout when you need the fallback to appear on reverts:key when you need Suspense to re-triggersuspensible to nested Suspense boundaries (Vue 3.3+)@pending, @resolve, and @fallback for programmatic loading stateRouterView -> Transition -> KeepAlive -> Suspense in that orderSuspense tracks a single immediate child in both slots. Wrap multiple elements in a single element or component.
BAD:
<template>
<Suspense>
<AsyncHeader />
<AsyncList />
<template #fallback>
<LoadingSpinner />
<LoadingHint />
</template>
</Suspense>
</template>
GOOD:
<template>
<Suspense>
<div>
<AsyncHeader />
<AsyncList />
</div>
<template #fallback>
<div>
<LoadingSpinner />
<LoadingHint />
</div>
</template>
</Suspense>
</template>
timeout)When Suspense is already resolved and new async work starts, the previous content remains visible until the timeout elapses. Use timeout="0" for immediate fallback or a short delay to avoid flicker.
BAD:
<template>
<Suspense>
<component :is="currentView" :key="viewKey" />
<template #fallback>
Loading...
</template>
</Suspense>
</template>
GOOD:
<template>
<Suspense :timeout="200">
<component :is="currentView" :key="viewKey" />
<template #fallback>
Loading...
</template>
</Suspense>
</template>
Once resolved, Suspense only re-enters pending when the root node of the default slot changes. If async work happens deeper in the tree, no fallback appears.
BAD:
<template>
<Suspense>
<TabContainer>
<AsyncDashboard v-if="tab === 'dashboard'" />
<AsyncSettings v-else />
</TabContainer>
<template #fallback>
Loading...
</template>
</Suspense>
</template>
GOOD:
<template>
<Suspense>
<component :is="tabs[tab]" :key="tab" />
<template #fallback>
Loading...
</template>
</Suspense>
</template>
suspensible for Nested Suspense (Vue 3.3+)Nested Suspense boundaries need suspensible on the inner boundary so the parent can coordinate loading state. Without it, inner async content may render empty nodes until resolved.
BAD:
<template>
<Suspense>
<LayoutShell>
<Suspense>
<AsyncWidget />
<template #fallback>
Loading widget...
</template>
</Suspense>
</LayoutShell>
<template #fallback>
Loading layout...
</template>
</Suspense>
</template>
GOOD:
<template>
<Suspense>
<LayoutShell>
<Suspense suspensible>
<AsyncWidget />
<template #fallback>
Loading widget...
</template>
</Suspense>
</LayoutShell>
<template #fallback>
Loading layout...
</template>
</Suspense>
</template>
Use @pending, @resolve, and @fallback for analytics, global loading indicators, or coordinating UI outside the Suspense boundary.
<script setup>
import { ref } from 'vue'
const isLoading = ref(false)
function onPending() {
isLoading.value = true
}
function onResolve() {
isLoading.value = false
}
</script>
<template>
<LoadingBar v-if="isLoading" />
<Suspense @pending="onPending" @resolve="onResolve">
<AsyncPage />
<template #fallback>
<PageSkeleton />
</template>
</Suspense>
</template>
When combining these components, the nesting order should be RouterView -> Transition -> KeepAlive -> Suspense so each wrapper works correctly.
BAD:
<template>
<RouterView v-slot="{ Component }">
<Suspense>
<KeepAlive>
<Transition mode="out-in">
<component :is="Component" />
</Transition>
</KeepAlive>
</Suspense>
</RouterView>
</template>
GOOD:
<template>
<RouterView v-slot="{ Component }">
<Transition mode="out-in">
<KeepAlive>
<Suspense>
<component :is="Component" />
<template #fallback>
Loading...
</template>
</Suspense>
</KeepAlive>
</Transition>
</RouterView>
</template>
In production code, keep Suspense boundaries minimal, document where they are used, and have a fallback loading strategy if you ever need to replace or refactor them.