Vue Suspense Not Working: The Async Setup Traps I Hit
Last month I rebuilt an admin dashboard in Vue, and I decided this time the loading states would be clean. One <Suspense> boundary around the whole page, a skeleton in the fallback, no more isLoading refs scattered across five components. I wired it up, refreshed, and the skeleton never appeared. The page sat blank for two seconds, then everything popped in at once. The boundary was right there in the tree. It did nothing.
That afternoon taught me more about Suspense than the docs page did. If your fallback never shows either, here are the traps, in the order I fell into them.
Suspense only waits for setup
My first version kept the fetch where it had always been, in onMounted:
<script setup>
// StatsPanel.vue
import { onMounted, ref } from 'vue'
import { getStats } from '@/api'
const stats = ref(null)
onMounted(async () => {
stats.value = await getStats()
})
</script>
This is the big one. Suspense tracks async dependencies declared during setup, meaning a top-level await, or a component built with defineAsyncComponent. A fetch inside onMounted starts after the component mounts, and by then the boundary has already resolved. The fallback has no reason to exist for that request. Suspense is not a loading-state manager for your app. It is a gate around setup.
The fix was moving the work up:
<script setup>
import { getStats } from '@/api'
// Top-level await makes this component an async dependency.
const stats = await getStats()
</script>
Anything registered after the first await is gone
Once I converted a panel to async setup, one of my onMounted callbacks just stopped running. No warning, no console message. The reason is that async setup resolves before the component instance is mounted, and after the first await the current instance is no longer guaranteed to be there when a hook registers.
const data = await fetchSummary()
// Never runs. No instance to attach to by this point.
onMounted(() => {
console.log('mounted', data)
})
The rule I follow now: every onMounted, onUnmounted, and any composable that registers hooks goes above the first await. Watchers have the same problem. A watcher created synchronously in setup gets stopped when the component unmounts, but one created after an await is not bound to the instance, so it leaks until the page dies. I hit exactly that, and it rhymes with what I wrote in my post about watchers that never stop.
One slow child holds the whole page
With the fetches moved into setup, the fallback finally showed. Then I noticed the audit log panel, which hits a slow endpoint, was delaying the stats panel that resolves in 80 milliseconds. That is by design. Suspense waits for every async dependency in its subtree, not the first one.
The fix is to stop treating it as one gate for the page and nest boundaries around the regions that load independently:
<Suspense>
<template #fallback><PageSkeleton /></template>
<Dashboard>
<Suspense><QuickStats /></Suspense>
<Suspense><AuditLog /></Suspense>
</Dashboard>
</Suspense>
Each region gets its own fallback and resolves on its own schedule, so the outer skeleton gives way as soon as the fast region is ready instead of waiting on the slow one.
Errors do not land in the fallback
When the audit endpoint returned a 500, I expected the fallback to catch it. It does not. Suspense has no error slot. An error thrown in async setup needs onErrorCaptured in a parent, or app.config.errorHandler globally:
onErrorCaptured((err, instance, info) => {
error.value = err
return false
})
The wrapper around this is fifteen lines and it used to live only in one project. After retyping it for the third time I dropped it into Snippet Ark, which is where every piece of boilerplate I keep retyping ends up eventually.
Two behaviors that look like bugs but are not
First, on a fast connection your fallback may never render at all. If every dependency resolves in the same tick, Vue skips the fallback state entirely. If you actually want users to glimpse the skeleton, you need a minimum display time, which is a hack, and I will leave my opinion about hacks like that out of this.
Second, when a dependency re-suspends on an update, the boundary keeps showing the old content by default instead of going back to the fallback. Usually that is what you want. It just looks wrong the first time you see it.
Also worth knowing: Suspense is still flagged experimental. It has been usable for years and Nuxt relies on it heavily, but its behavior can shift between minor releases, so treat anything exotic you build on top of it with some suspicion.
The Nuxt footnote
That last point matters if you are on Nuxt, because every page already lives inside a Suspense boundary. A top-level await in a page's setup blocks navigation until it resolves, which is why a slow API call on one page can make route changes feel dead. I wrote about the SSR side of this in what actually gets dropped during SSR streaming, and the two posts pair well if you are debugging a slow first paint.
Suspense is good once you accept what it actually is: a gate around setup, nothing more. Fight that, and you get blank pages and missing hooks. Work with it, and the loading states mostly write themselves.