[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-vue-suspense-not-working-async-setup-traps":3},{"content":4,"lastModified":5},"\u003Cp>Last month I rebuilt an admin dashboard in Vue, and I decided this time the loading states would be clean. One \u003Ccode>&lt;Suspense&gt;\u003C\u002Fcode> boundary around the whole page, a skeleton in the fallback, no more \u003Ccode>isLoading\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cimg src=\"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1587620962725-abab7fe55159?w=1200&amp;q=80&amp;auto=format&amp;fit=crop\" alt=\"A laptop on a desk with frontend template code open in an editor, the view during a Suspense debugging session\" loading=\"lazy\" \u002F>\n\n\u003Ch2>Suspense only waits for setup\u003C\u002Fh2>\n\n\u003Cp>My first version kept the fetch where it had always been, in \u003Ccode>onMounted\u003C\u002Fcode>:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-vue\">&lt;script setup&gt;\n\u002F\u002F StatsPanel.vue\nimport { onMounted, ref } from 'vue'\nimport { getStats } from '@\u002Fapi'\n\nconst stats = ref(null)\n\nonMounted(async () =&gt; {\n  stats.value = await getStats()\n})\n&lt;\u002Fscript&gt;\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>This is the big one. Suspense tracks async dependencies declared during setup, meaning a top-level \u003Ccode>await\u003C\u002Fcode>, or a component built with \u003Ccode>defineAsyncComponent\u003C\u002Fcode>. A fetch inside \u003Ccode>onMounted\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>The fix was moving the work up:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-vue\">&lt;script setup&gt;\nimport { getStats } from '@\u002Fapi'\n\n\u002F\u002F Top-level await makes this component an async dependency.\nconst stats = await getStats()\n&lt;\u002Fscript&gt;\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Ch2>Anything registered after the first await is gone\u003C\u002Fh2>\n\n\u003Cp>Once I converted a panel to async setup, one of my \u003Ccode>onMounted\u003C\u002Fcode> 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 \u003Ccode>await\u003C\u002Fcode> the current instance is no longer guaranteed to be there when a hook registers.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">const data = await fetchSummary()\n\n\u002F\u002F Never runs. No instance to attach to by this point.\nonMounted(() =&gt; {\n  console.log('mounted', data)\n})\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>The rule I follow now: every \u003Ccode>onMounted\u003C\u002Fcode>, \u003Ccode>onUnmounted\u003C\u002Fcode>, and any composable that registers hooks goes above the first \u003Ccode>await\u003C\u002Fcode>. Watchers have the same problem. A watcher created synchronously in setup gets stopped when the component unmounts, but one created after an \u003Ccode>await\u003C\u002Fcode> 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 \u003Ca href=\"\u002Fposts\u002Fvue-watch-cleanup-watchers-never-stop\u002F\">watchers that never stop\u003C\u002Fa>.\u003C\u002Fp>\n\n\u003Ch2>One slow child holds the whole page\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>The fix is to stop treating it as one gate for the page and nest boundaries around the regions that load independently:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-vue\">&lt;Suspense&gt;\n  &lt;template #fallback&gt;&lt;PageSkeleton \u002F&gt;&lt;\u002Ftemplate&gt;\n  &lt;Dashboard&gt;\n    &lt;Suspense&gt;&lt;QuickStats \u002F&gt;&lt;\u002FSuspense&gt;\n    &lt;Suspense&gt;&lt;AuditLog \u002F&gt;&lt;\u002FSuspense&gt;\n  &lt;\u002FDashboard&gt;\n&lt;\u002FSuspense&gt;\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>Errors do not land in the fallback\u003C\u002Fh2>\n\n\u003Cp>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 \u003Ccode>onErrorCaptured\u003C\u002Fcode> in a parent, or \u003Ccode>app.config.errorHandler\u003C\u002Fcode> globally:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">onErrorCaptured((err, instance, info) =&gt; {\n  error.value = err\n  return false\n})\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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 \u003Ca href=\"\u002Fsnippetark\u002F\" rel=\"noopener noreferrer\" target=\"_blank\">Snippet Ark\u003C\u002Fa>, which is where every piece of boilerplate I keep retyping ends up eventually.\u003C\u002Fp>\n\n\u003Ch2>Two behaviors that look like bugs but are not\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>The Nuxt footnote\u003C\u002Fh2>\n\n\u003Cp>That last point matters if you are on Nuxt, because every page already lives inside a Suspense boundary. A top-level \u003Ccode>await\u003C\u002Fcode> 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 \u003Ca href=\"\u002Fposts\u002Fnuxt-ssr-streaming-what-gets-dropped\u002F\">what actually gets dropped during SSR streaming\u003C\u002Fa>, and the two posts pair well if you are debugging a slow first paint.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n","2026-10-22",1793106023373]