skip to content

After a Vue 3 `<Suspense>` has resolved, what makes it pending again, and what do its timeout prop and pending, resolve and fallback events control?

level: middleimportance: nice to knowfreq 30%

answer

  1. only the root of default counts
  2. deeper new dependencies are ignored
  3. old content stays by default
  4. timeout 0 means fallback at once

basics

~20 s

A resolved <Suspense> goes pending again only when the root node of its #default slot is replaced. It then keeps showing the old content until the new content resolves, unless timeout sets when to switch to #fallback; pending, fallback and resolve events report each transition.

solid answer

~40 s

Once resolved, `<Suspense>` re-enters the **pending** state only if the **root node of `#default`** is replaced, for example when `<component :is>` switches to another component. New async dependencies deeper in the tree do not revert it. On a revert it keeps the **previous content** on screen while rendering the new content in memory; the `timeout` prop changes that: after `timeout` milliseconds it switches to `#fallback`, and `timeout` of `0` shows the fallback immediately. Without `timeout`, it never switches to the fallback on a revert and swaps directly when the new content resolves. The events report each step: `pending` when a pending state starts, `fallback` when the fallback slot is shown, `resolve` when new default content has finished. On the first render with dependencies, `pending` and `fallback` fire together.

go deeper

for a junior

Remember that once Suspense has resolved, it keeps old content by default instead of flashing the fallback again.

for a middle

Explain the root-replacement rule for reverts, the three timeout behaviours and the order of pending, fallback and resolve.

for a senior

Design switching views around these rules: nested boundaries for deep dependencies, a progress indicator driven by pending and resolve, and a timeout tuned to avoid flicker.

for a principal

Set a house rule for perceived loading: stale-while-loading versus skeletons, and apply it consistently through one wrapper around Suspense.

## Two phases of a boundary's life A `<Suspense>` boundary behaves differently on its **first render** and on **later changes**: - On the first render, if it meets async dependencies, it shows `#fallback` at once, because there is no previous content to keep. - After it has resolved, it already has good content on screen. Replacing that with a spinner on every change would feel worse than keeping the old view a moment longer, so the rules change. ## What reverts a resolved boundary Only one thing: the **root node of the `#default` slot being replaced**. Typical triggers: - `<component :is="current">` as the root, and `current` changes to a different component; - a root element or component whose `key` changes, which Vue treats as a different node. What does **not** revert it: a new async component or async-setup component appearing **deeper** in the tree, for example a widget toggled on by a `v-if` inside the root. Such a dependency resolves on its own and renders when ready; the boundary stays resolved, so there is no fallback for it. If you need one there, give that area its own nested boundary. ## What happens during a revert 1. The boundary emits `pending`. 2. It renders the new root **in memory** while the old content stays visible. 3. If the new content has no async dependencies, it swaps immediately. 4. Otherwise it waits, and the `timeout` prop decides whether the fallback appears in the meantime. 5. When the new content resolves, it replaces the visible content and emits `resolve`. ## The timeout prop | `timeout` | Behaviour on a revert | |---|---| | not set | keep showing the previous content until the new content resolves; the fallback is not shown | | `0` | show `#fallback` immediately when the default content is replaced | | `n > 0` | keep the previous content for up to `n` ms, then switch to `#fallback` if still pending | The prop only matters for reverts. On the very first render there is nothing to keep, so the fallback is shown right away. The value can be a number or a numeric string. ## The three events | Event | Fires when | |---|---| | `pending` | the boundary enters a pending state (first render with dependencies, or a revert) | | `fallback` | the `#fallback` content is shown | | `resolve` | new `#default` content has finished resolving and is displayed | On a first render with dependencies, `pending` and `fallback` fire together. On a revert with no `timeout`, you see `pending` and later `resolve`, with no `fallback` in between. If the first render meets no dependency, the boundary resolves directly and emits only `resolve`. The Vue guide suggests a use for them: showing a small loading indicator **in front of the old DOM** while the new view loads, which pairs naturally with leaving `timeout` unset. ## An example: tabbed reports ```vue <Suspense :timeout="300" @pending="busy = true" @resolve="busy = false"> <component :is="currentReport" /> <template #fallback><ReportSkeleton /></template> </Suspense> <TopProgressBar v-if="busy" /> ``` Switching tabs keeps the previous report visible with a progress bar; if the new report takes longer than 300 ms, the skeleton replaces it. ## Choosing a timeout value The right value depends on how fast the new views usually load and what the old content means to the user: - **Leave it unset** when the previous view is still valid to look at, for example switching between report tabs, and show progress through `pending`/`resolve` instead. - **Use a short positive value** when most switches are fast but a few are slow: quick switches never flash a skeleton, slow ones eventually show it, so the user is not left staring at stale content without a hint. - **Use `0`** when showing the old content would mislead, for example switching between two customers' records, where a moment of the wrong customer's data is worse than a skeleton. Measure real switch times before settling on a number; a value much shorter than typical loads just reintroduces the flicker the default avoids. ## Pitfalls - **Expecting the fallback for a deep toggle**: only a root replacement reverts the boundary. - **Expecting `timeout` to limit loading time**: it does not cancel anything or show an error; it only decides when to switch to the fallback. - **Assuming `fallback` fires on every revert**: with no `timeout`, it does not.

  • Why would you listen to @pending and @resolve on a Vue 3 <Suspense> instead of using its fallback?
    To keep the previous view on screen while showing a lightweight indicator, such as a top progress bar, during a revert. With `timeout` unset, the boundary never shows the fallback on a revert, so `pending` and `resolve` are the only signals that loading has started and finished.
  • A widget toggled on by v-if deep inside a resolved Vue 3 <Suspense> has async setup. What does the user see while it loads?
    No fallback. A new dependency below the root does not revert a resolved boundary, so the rest of the content stays and the widget appears once its setup resolves. If a loading state is wanted there, wrap that widget in its own `<Suspense>` or give it a local loading branch.

saying these in an interview costs you the question

  • Any new async component in the tree re-triggers the fallback
  • timeout cancels loading and shows an error after the delay
  • Without timeout, the fallback shows on every revert
  • The fallback event fires every time the boundary becomes pending
  • timeout also delays the fallback on the first render