Vue 3 Suspense & Async Boundaries
Vue 3's Suspense component gives you React-like async boundaries with finer control over loading and error states. Use it to keep dashboards responsive, hydrate SSR pages smoothly, and coordinate multiple async dependencies. If you are structuring large codebases, tie this into the architecture lessons from Vue Router Navigation Patterns so async pages still feel cohesive.
Suspense Building Blocks
<Suspense>
<template #default>
<ProjectSummary />
</template>
<template #fallback>
<SkeletonSummary />
</template>
</Suspense>
<template #default>renders after all async dependencies resolve.<template #fallback>displays immediately and stays visible until everything insidedefaultsettles.- Components inside
<Suspense>can return Promises fromsetupor use async<script setup>functions.
Example: Fetching Data With async setup
<script setup lang="ts">
import { ref } from 'vue';
import { getProjectStats } from '@/api/projects';
const stats = ref(await getProjectStats());
</script>
<template>
<dl class="stats">
<div v-for="stat in stats" :key="stat.label">
<dt>{{ stat.label }}</dt>
<dd>{{ stat.value }}</dd>
</div>
</dl>
</template>
Because setup is async, the component suspends until the Promise resolves. Place it under <Suspense> to control the UX.
Coordinating Multiple Async Sources
<Suspense>
<template #default>
<div class="grid">
<UserProfile />
<RecentActivity />
</div>
</template>
<template #fallback>
<DashboardSkeleton />
</template>
</Suspense>
Suspense waits for all children to resolve. For partial rendering, split the dashboard into multiple Suspense boundaries or hand off state fetching to stores such as Pinia State Management.
<div class="grid">
<Suspense>
<template #default><UserProfile /></template>
<template #fallback><SkeletonProfile /></template>
</Suspense>
<Suspense>
<template #default><RecentActivity /></template>
<template #fallback><SkeletonList /></template>
</Suspense>
</div>
Error Handling with onErrorCaptured
Wrap Suspense in an error boundary to keep the rest of the page functional.
<script setup lang="ts">
import { ref } from 'vue';
const error = ref<string | null>(null);
function resetError() {
error.value = null;
}
</script>
<template>
<ErrorBoundary @reset="resetError">
<template #default>
<Suspense>
<template #default>
<InvoicesTable />
</template>
<template #fallback>
<InvoicesSkeleton />
</template>
</Suspense>
</template>
<template #error="{ err }">
<div class="alert">
<p>We could not load invoices.</p>
<pre>{{ err.message }}</pre>
<button @click="resetError">Retry</button>
</div>
</template>
</ErrorBoundary>
</template>
ErrorBoundary is a tiny wrapper around Vue's onErrorCaptured hook that emits the caught error. The official Vue error handling guide walks through the underlying APIs if you need a refresher.
Suspense + Routing
Vue Router 4 supports lazy-loaded routes that integrate nicely with Suspense. Use defineAsyncComponent or dynamic imports on the route level and wrap <RouterView> with a boundary.
<template>
<Suspense>
<template #default>
<RouterView />
</template>
<template #fallback>
<PageSkeleton />
</template>
</Suspense>
</template>
Streaming SSR
When using @vue/server-renderer, Suspense enables streaming HTML back to the client as soon as fallback content is ready. This keeps the above-the-fold paint fast while longer API calls resolve in the background.
Tips:
- Keep fallback templates lightweight so the first chunk streams immediately.
- Use
client-onlycomponents around widgets that rely on browser APIs. - Pair Suspense with
v-memoorshallowRefto avoid re-running expensive fetches once resolved.
Performance Tips
- Cache API calls: Inject a cache layer (e.g.,
@tanstack/vue-query) to avoid repeated suspensions on the same route. - Timeouts: Provide a manual timeout to reveal alternative fallback UI when requests exceed SLA.
- Skeletons > Spinners: Skeleton screens communicate progress better and reduce layout shift.
Common Pitfalls
- Deeply nested Suspense: Too many boundaries can make waterfalls worse. Group related async work together.
- Missing keys: Always set stable keys on lists rendered inside Suspense; otherwise Vue cannot diff skeletons vs loaded content.
- Server/client mismatch: Ensure fallback markup is deterministic (no random data) so hydration succeeds.
Frequently Asked Questions
Can I use Suspense inside <script setup> only components?
Yes. Any async logic inside setup or Composition API functions (e.g., useFetch) can trigger suspension.
How do I retry a Suspense boundary?
Expose a retry method from your data composable or call Suspense's built-in resolve/reveal functions via refs. Simpler approach: toggle the component with v-if to recreate it.
Does Suspense replace loading indicators in Pinia or Vue Query?
No. Suspense controls the first paint. You should still surface inline loading states (e.g., refresh spinners) for interactions that happen after initial render.
Conclusion
Suspense lets you stream meaningful loading states, recover from errors, and compose async widgets without manual state plumbing. Combine it with good caching and skeleton designs to keep Vue dashboards responsive on any network.
Next Steps
- Identify routes/components that block rendering due to API calls and wrap them in Suspense boundaries.
- Pair Suspense with Pinia or Vue Query for caching and retries.
- Experiment with streaming SSR to speed up time-to-first-byte on content-heavy pages.
Additional Resources
- Vue Router Navigation Patterns – design route layouts that benefit from nested Suspense boundaries.
- Composables & Reusable Logic – reuse async data fetchers across Suspense-aware components.
- Pinia State Management – cache async responses so Suspense resolves instantly on repeat visits.
- Vue Suspense docs – canonical API and caveats straight from the core team.