skip to content

In Vue 3, a defineAsyncComponent chart panel under a <Suspense> never shows its loadingComponent and a hung chunk never times out; why, and how do you harden its error path?

level: seniorimportance: should knowfreq 30%

answer

  1. who owns the loading state
  2. suspensible is on by default
  3. timers only in self-managed mode
  4. retry, fail, attempts
  5. late chunks still win

basics

~20 s

Vue async components are suspensible by default, so under a Suspense ancestor the boundary controls loading and the wrapper's loadingComponent, delay and timeout go unused. Set suspensible: false or rely on the fallback, then add timeout, errorComponent and a capped onError retry.

solid answer

~40 s

`defineAsyncComponent` defaults to `suspensible: true`. When the wrapper finds a `<Suspense>` ancestor, it hands its load promise to that boundary and returns; the boundary's `#fallback` is the loading UI, and the wrapper never arms its `delay` or `timeout` timers or renders `loadingComponent`. So a hung chunk leaves the fallback up indefinitely. Two ways out: set `suspensible: false` so the wrapper manages its own states, or keep Suspense in charge and handle timing at the boundary. Then harden the failure path: an explicit `timeout`, an `errorComponent` that receives the `error` prop, and `onError(error, retry, fail, attempts)` that retries transient chunk failures a bounded number of times. Loader errors also reach `onErrorCaptured` and `app.config.errorHandler`, which is where you report them. A chunk that arrives after the timeout still replaces the error view.

code

ts · 11 lines
ts
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// async loader failures arrive here after any onErrorCaptured ancestors
app.config.errorHandler = (err, instance, info) => {
  console.error('[ui error]', info, err)
}

app.mount('#app')

go deeper

for a junior

Know that async components cooperate with Suspense by default and that the suspensible option turns that off.

for a middle

Explain which options apply in each mode and why no timer runs when a Suspense boundary is in control.

for a senior

Diagnose the missing skeleton and endless spinner, then design timeout, error view, bounded retry and error reporting for a chunk that can fail in production.

for a principal

Set a policy for where loading and error states live, per boundary or per panel, so a large app does not end up with inconsistent spinners and silent failures.

## Why the wrapper's own options go quiet A `defineAsyncComponent` wrapper has two operating modes, and the `suspensible` option (default `true`) decides between them: | | Self-managed mode | Suspense-controlled mode | |---|---|---| | When | No `<Suspense>` ancestor, or `suspensible: false` | A `<Suspense>` ancestor and `suspensible: true` | | Loading UI | `loadingComponent` after `delay` | The boundary's `#fallback` slot | | `timeout` | Armed; turns a pending load into an error | Not armed | | On rejection | `errorComponent` with an `error` prop | Error reported; `errorComponent` rendered if provided | In Suspense-controlled mode the wrapper's `setup` simply returns the load promise, and the boundary waits on it together with every other async dependency below it. The docs summarise this as the component's own loading, error, delay and timeout options being ignored. Reading the Vue 3.5 source is slightly more precise: `loadingComponent`, `delay` and `timeout` are genuinely unused, while a rejected load still renders `errorComponent` if one is given. Either way, the symptom in the question follows directly: **no loading component, and no timeout**, because no timer is ever started. ## Choosing who owns the loading state - **Let Suspense own it** when several async pieces of a view should appear together behind one fallback. Then any timing or error policy belongs at the boundary level, not in each wrapper. - **Set `suspensible: false`** when this panel should manage its own skeleton and error view independently of the surrounding page, for example a heavy chart that may load long after the rest of the dashboard. ## Hardening the failure path Chunk loads fail for ordinary reasons: a flaky mobile connection, or a deployment that removed an old chunk while a user still had the previous page open. A robust wrapper does four things: 1. **Sets `timeout`**, because the default is to wait forever. 2. **Provides `errorComponent`**, which receives the failure as an `error` prop and can offer a reload or retry action. 3. **Uses `onError(error, retry, fail, attempts)`** for transient failures. `attempts` starts at 1 for the first failure. The handler must call exactly one of `retry()` or `fail()`: the wrapper waits on that decision, so a handler that only logs leaves the load pending until a timeout, if any, fires. 4. **Reports the error**. Loader failures go through Vue's error path, ancestor `onErrorCaptured` hooks first, then `app.config.errorHandler`. Without an `errorComponent` and without any handler, a development build rethrows. Two behaviours are worth knowing when you reason about what users see: - A **late chunk still wins**: the timeout only switches the wrapper to its error view; the loader promise is not cancelled, and if it resolves later the real panel replaces the error view. - A **failed load is not cached**: the wrapper clears its pending request on error, so the next time the panel is rendered the loader runs again. ## Example ```ts import { defineAsyncComponent } from 'vue' import ChartSkeleton from './ChartSkeleton.vue' import ChartLoadError from './ChartLoadError.vue' export const RevenueChart = defineAsyncComponent({ loader: () => import('./RevenueChart.vue'), loadingComponent: ChartSkeleton, errorComponent: ChartLoadError, timeout: 10_000, suspensible: false, onError(error, retry, fail, attempts) { if (attempts <= 2 && /fetch|import/i.test(error.message)) { retry() } else { fail() } }, }) ``` This wrapper ignores any surrounding boundary, shows a skeleton after the 200 ms default delay, gives up after ten seconds, and retries a network-shaped failure twice before showing the error view. Tune the retry test to the error messages your bundler actually produces; the regular expression here is illustrative.

  • In Vue 3, an `onError` handler passed to `defineAsyncComponent` logs the error but calls neither `retry()` nor `fail()`. What happens?
    The wrapper waits for the handler's decision, so the load stays pending: the loading view (if any) remains, and only a configured `timeout` can move it to the error state. Always end the handler with exactly one of `retry()` or `fail()`.
  • Should a dashboard keep its async charts suspensible or not?
    Keep them suspensible when the dashboard should appear as one unit behind a single fallback, and put the timing and error policy at that boundary. Set `suspensible: false` on a chart that is slow or optional, so it can show its own skeleton and error view without holding the rest of the page behind the fallback.

saying these in an interview costs you the question

  • Under Suspense, the async component's timeout still fires and shows errorComponent.
  • delay: 0 makes loadingComponent appear even inside a Suspense boundary.
  • Vue retries a failed async component load a few times automatically.
  • Once a chunk times out, a late response is discarded as stale.
  • An onError handler that only logs lets Vue fall back to the error view.