skip to content

In a Vue 3 dashboard whose widgets use top-level await, when does a `<Suspense>` around it show the widgets, and what delays them?

level: middleimportance: should knowfreq 45%

answer

  1. sibling awaits start together
  2. nested awaits form a waterfall
  3. the slowest dependency decides
  4. mounted hooks are held back

basics

~20 s

The widgets appear together once every async dependency Suspense found has resolved, so the slowest chain of dependencies decides. Sibling widgets' awaits run in parallel, but a widget nested inside another async component only starts after its parent resolves, creating a waterfall.

solid answer

~40 s

`<Suspense>` renders the dashboard in memory and counts each component with an async `setup()` and each async component it meets. Sibling widgets are created in the same render pass, so their top-level `await`s run **concurrently**, and the boundary resolves when the **slowest** finishes. A widget nested inside another async component is different: the child is not even created until its parent's setup resolves and renders, so its request starts only then, a **waterfall**. Until resolution, the widgets' `onMounted` hooks are held back, and they run once the content is in the page. Two practical fixes follow: lift requests that nest into the common parent or start them together, and give a slow, non-essential widget its own inner boundary or its own loading state so it does not hold back the whole dashboard.

code

vue · 15 lines
vue
<script setup lang="ts">
// RevenueCard.vue: an async dependency of the nearest <Suspense>
const [summary, trend] = await Promise.all([
  fetch('/api/revenue/summary').then(r => r.json()),
  fetch('/api/revenue/trend').then(r => r.json()),
])
</script>

<template>
  <section class="card">
    <h2>Revenue</h2>
    <p>{{ summary.total }}</p>
    <TrendLine :points="trend" />
  </section>
</template>

go deeper

for a junior

Remember that the widgets appear together, only after the slowest one has resolved.

for a middle

Explain dependency counting during the in-memory render, parallel sibling setups, nested waterfalls and the buffered onMounted hooks.

for a senior

Fix slow dashboards structurally: hoist or parallelise requests, split boundaries for non-essential widgets, and drop awaits for data that need not block.

for a principal

Choose the granularity of loading boundaries as a product decision: one reveal feels coherent but ties the page to its slowest request.

## The scenario A dashboard page has four widgets: `RevenueCard`, `OrdersChart`, `TopProducts` and `AlertsPanel`. Each is a `<script setup>` component that awaits its own API call at the top level, so each has an async `setup()`. The page wraps the grid in one boundary: ```vue <Suspense> <DashboardGrid /> <template #fallback><DashboardSkeleton /></template> </Suspense> ``` The product question is when the widgets appear, and why the page sometimes feels slower than any single request. ## How the boundary counts dependencies 1. `<Suspense>` renders `DashboardGrid` into an off-document container. 2. Rendering the grid creates the four widgets. Each widget's setup starts, hits its `await`, and returns a promise, so each registers as a **dependency**; the boundary's counter is now 4. 3. The skeleton is shown, and the `pending` and `fallback` events fire. 4. As each promise settles, that widget finishes setup and renders, still off-document. If its render creates new async components, they are counted too. 5. When the counter reaches 0, the skeleton is removed, the whole grid is moved into the page at once, and `resolve` fires. ## Parallel siblings, sequential nesting | Layout | When requests start | Total wait | |---|---|---| | Four sibling widgets under the grid | all during the same render pass | about the slowest request | | `OrdersChart` renders an async `OrdersTable` inside it | the table's request starts after the chart's setup resolves | chart time **plus** table time | | Widget awaits two calls one after the other | the second after the first | sum of both | Sibling setups run concurrently because Vue creates all of them before any finishes. Nested async components form a **waterfall** because a child only exists once its parent has rendered, and an async parent renders only after its setup resolves. Inside one setup, consecutive `await`s are sequential too; `Promise.all` starts them together. ## What is held back - **The DOM**: nothing inside the boundary is visible until everything resolves. - **`onMounted` and similar post-render hooks** of components in the pending content: Vue buffers them and runs them at resolution, when the elements are really in the page. - **Async components' own loading options**: async components are suspensible by default, so under a boundary their own loading and error handling is ignored and the boundary controls the loading state, unless they opt out with `suspensible: false`. ## Keeping one slow widget from blocking the page - **Start requests earlier and together**: fetch in the grid, or with `Promise.all`, instead of nesting async components. - **Split boundaries**: wrap `AlertsPanel` in its own `<Suspense>` with a small fallback, so the rest of the dashboard can resolve without it; an inner boundary is treated like a synchronous component by the outer one. - **Drop the `await` for non-critical data**: start the request without awaiting it and render a local loading state, so the widget stops being a dependency. ## Measuring where the time goes Before restructuring, confirm which pattern the page has: 1. Open the browser's network panel and reload the dashboard. Requests that start at the same moment are parallel siblings; a request that starts only when another finishes is a waterfall. 2. Log a timestamp at the top of each widget's setup and after its `await`. A setup that begins late points at an async ancestor. 3. Listen to the boundary's `resolve` event and log the time since navigation; that is the number users feel. 4. Re-measure after each change, since hoisting one request can expose the next slowest one. ## Common misconceptions - Suspense does **not** reveal widgets one by one as they finish; it reveals the subtree once. - A widget that starts a fetch without awaiting it is **not** counted. - The dependencies are counted as they are **encountered** during rendering, so dependencies that appear only after others resolve extend the wait. ## What interviewers listen for That the candidate can predict the timeline: parallel siblings, waterfall nesting, all-at-once reveal, deferred `onMounted`, and knows the structural fixes rather than just raising the timeout of a spinner.

  • In this Vue 3 dashboard, when does a widget's onMounted run?
    When the boundary resolves. While the content is pending, it lives off-document, and Vue buffers post-render hooks such as `onMounted` in the boundary. They are flushed once all dependencies are done and the content has been moved into the page, so DOM measurements in them see real layout.
  • How does an inner <Suspense> around one slow widget change the outer boundary's behaviour?
    Without the `suspensible` prop, the outer boundary treats the inner one like a synchronous component: the inner boundary shows its own fallback and waits on its own. The outer boundary can then resolve and show the rest of the dashboard while the slow widget is still loading.

saying these in an interview costs you the question

  • Suspense shows each widget as soon as its own data arrives
  • Nested async components load in parallel with their parents
  • Two consecutive awaits in one setup run concurrently
  • A widget's onMounted runs while the fallback is still showing
  • An unawaited fetch in setup also delays the boundary