Handling Async Errors in Vue 3 Composition API Setup

Async errors thrown inside <script setup> and setup() functions occupy a grey zone in Vue 3’s error pipeline: Vue captures some of them, silently drops others, and routes almost none of them through the onErrorCaptured hook in a way developers expect. This page explains exactly which async errors reach Vue 3 Error Capturing and Fallback Strategies and which escape to raw unhandledrejection events, drawing on the runtime interception foundations in Core JavaScript Error Handling & Boundaries.

Async error routing paths in Vue 3 setup()Three columns show three async patterns. Column 1: synchronous throw in setup() reaches onErrorCaptured. Column 2: fire-and-forget promise escapes to window unhandledrejection. Column 3: awaited promise inside Suspense reaches onErrorCaptured on the parent of Suspense.Async Error Routing in setup()sync throw in setup()throw new Error()onErrorCaptured+ errorHandlercaptured by Vuefire-and-forget promisefetchData() no awaitunhandledrejectionwindow event onlyescapes Vue pipelineasync setup + Suspenseawait fetch() throwsonErrorCapturedparent of Suspensecaptured if wrappedWithout Suspense, defineAsyncComponent errors also escape to unhandledrejection.Wrap with try/catch and map failures to reactive state, or use Suspense.

Symptom / Trigger

The component renders partially or shows nothing, and you see this in the browser console while your telemetry platform reports nothing:

Uncaught (in promise) Error: Failed to fetch user profile
    at fetchProfile (profile.js:12)
    at setup (ProfileCard.js:8)

Or, with Vue’s development overlay suppressed in production, you see no error at all — the component simply disappears. app.config.errorHandler was never called. No onErrorCaptured hook fired on any ancestor.

Root Cause Explanation

Vue 3 wraps synchronous setup() execution in a try/catch internally. A synchronous throw inside setup() is caught immediately and routed through the error pipeline. However, Vue does not await the return value of setup() when the function is not wrapped by <Suspense>. This means a Promise that rejects after setup() returns is an orphaned rejection — no Vue code is observing it.

// BrokenComponent.vue — the problematic pattern
import { onMounted } from 'vue';

export default {
  async setup() {
    // Vue calls setup(), receives a Promise, does NOT await it outside Suspense
    const user = await fetchUserProfile(); // If this rejects → unhandledrejection
    return { user };
  }
};

When fetchUserProfile() rejects, Vue has already moved on. The rejection surfaces as an unhandledrejection browser event. app.config.errorHandler is never invoked. The component is not unmounted cleanly — it simply never finishes mounting, leaving its slot in the DOM tree in an undefined state.

The same silent failure occurs with fire-and-forget calls inside <script setup>:

// <script setup> — also broken
import { ref } from 'vue';
const data = ref(null);

// No await, no .catch() — rejects silently
fetch('/api/data').then(r => r.json()).then(d => { data.value = d; });

The distinction hinges on a single decision inside Vue’s setupComponent routine. When Vue detects that setup() returned a thenable, it only chains a rejection handler onto that Promise if the component instance carries a Suspense boundary reference (instance.suspense). Outside a <Suspense> subtree that reference is null, so Vue calls handleSetupResult synchronously with the still-pending Promise and returns control to the renderer. The Promise then settles a microtask later with no Vue code observing it. This timing is exactly why the failure cannot be caught by an ancestor onErrorCaptured: by the moment the rejection actually fires, the synchronous render pass that the hook guards has already finished walking the component tree. The same reasoning explains fire-and-forget calls — the .then callback runs in a fresh microtask with no try/catch frame on the stack, so the JavaScript engine flags the rejection as unhandled and dispatches the global browser event instead of surfacing it to Vue.

Unhandled rejection versus reactive error stateBefore column: a fire-and-forget promise with no await or catch rejects silently and escapes the Vue pipeline. After column: the same call wrapped in try/catch stores the failure in a reactive ref that the template renders.FIRE-AND-FORGETNo await or .catch()Rejects silentlyEscapes Vue pipelineTRY / CATCHWrapped in try/catcherror stored in refTemplate v-if renders

Step-by-Step Fix

1. Wrap async operations in try/catch and map to reactive state

The safest pattern for <script setup> is to never let a rejection go unhandled. Use a local try/catch inside an async function and expose the error as reactive state for template-level rendering decisions.

// <script setup> — correct pattern
import { ref, onMounted } from 'vue';

const data = ref(null);
const error = ref(null);
const loading = ref(false);

async function load() {
  loading.value = true;
  error.value = null;
  try {
    const res = await fetch('/api/data');
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    data.value = await res.json();
  } catch (err) {
    error.value = err; // drives template v-if, not unhandledrejection
  } finally {
    loading.value = false;
  }
}

onMounted(load); // called inside Vue's lifecycle — errors here DO reach errorHandler

Calling the async function inside onMounted means Vue wraps the call in its own error handling for the lifecycle hook phase. A rejection that escapes load() (because you forgot a try/catch branch) will reach app.config.errorHandler. This is a safer fallback net than calling the async function directly in top-level <script setup> scope.

2. Extract the pattern into a reusable composable

// composables/useAsync.js
import { ref, onMounted } from 'vue';

export function useAsync(fn, immediate = true) {
  const data = ref(null);
  const error = ref(null);
  const pending = ref(false);

  async function execute(...args) {
    pending.value = true;
    error.value = null;
    try {
      data.value = await fn(...args);
    } catch (err) {
      error.value = err;
    } finally {
      pending.value = false;
    }
  }

  if (immediate) onMounted(execute); // triggers after component mounts

  return { data, error, pending, execute };
}
// <script setup> — clean usage
import { useAsync } from '../composables/useAsync';

const { data, error, pending, execute } = useAsync(() => fetch('/api/items').then(r => r.json()));

This composable never lets a rejection escape — error.value is always set, and execute can be called again from a retry button without creating new unhandled rejections.

useAsync composable error flowA three-stage pipeline: an async function is passed to the useAsync composable, which runs it inside try/catch and writes any failure to a reactive error ref rather than letting it escape as an unhandled rejection.async fn()may rejectuseAsync wrapstry / catcherror ref setno escape

3. Use Suspense for top-level async setup() with error boundary wrapping

When an async component must use await at the top level of setup() (for example, to conditionally initialise state before first render), wrap it in <Suspense> and place an onErrorCaptured ancestor immediately outside:

<!-- ParentWithBoundary.vue -->
<script setup>
import { ref, onErrorCaptured, defineAsyncComponent } from 'vue';

const loadError = ref(null);

onErrorCaptured((err) => {
  loadError.value = err;
  return false; // stop propagation to global handler
});

const AsyncProfile = defineAsyncComponent(() => import('./AsyncProfile.vue'));
</script>

<template>
  <div v-if="loadError" role="alert">
    Profile unavailable: {{ loadError.message }}
    <button @click="loadError = null">Retry</button>
  </div>
  <Suspense v-else>
    <AsyncProfile />
    <template #fallback><LoadingSpinner /></template>
  </Suspense>
</template>
<!-- AsyncProfile.vue — safe to use top-level await here because Suspense wraps it -->
<script setup>
const res = await fetch('/api/profile');
if (!res.ok) throw new Error(`Profile load failed: ${res.status}`); // reaches onErrorCaptured above
const profile = await res.json();
</script>

<template>
  <div>{{ profile.name }}</div>
</template>

Vue wraps the async setup() execution of a component inside <Suspense> so that the rejection propagates to the nearest onErrorCaptured. Without <Suspense>, the same throw inside an async setup() becomes an unhandled rejection.

Suspense async error propagationVertical flow of three stages: a top-level await in async setup throws, Suspense re-throws that rejection into the component tree, and the nearest onErrorCaptured hook on the Suspense parent catches it.await throws in setup()Suspense re-throwsonErrorCaptured catches

4. Register a global unhandledrejection fallback for remaining escapes

Even with the patterns above, some async errors may slip through — third-party composables, dynamically loaded plugins, or edge cases in error re-throw chains. Register a window listener as a last-resort capture that feeds your telemetry SDK:

// main.js — after app.config.errorHandler registration
window.addEventListener('unhandledrejection', (event) => {
  captureException(event.reason ?? new Error('Unknown unhandled rejection'), {
    vueInfo: 'unhandledrejection fallback',
    url: window.location.href,
  });
  event.preventDefault(); // suppress the browser console warning if you've logged it
});

This complements rather than replaces the Vue-level handler. The event.preventDefault() call stops the browser from printing the rejection to the console a second time after your SDK has already captured it.

Verification

After applying the patterns, trigger a controlled failure and confirm the error reaches your handler — not the unhandledrejection listener:

// verification: use Vue Test Utils to confirm async setup errors are captured
import { mount, flushPromises } from '@vue/test-utils';
import { defineComponent, ref, onMounted } from 'vue';

const globalErrorHandler = vi.fn();

const FailingComponent = defineComponent({
  setup() {
    const error = ref(null);
    onMounted(async () => {
      try {
        await Promise.reject(new Error('deliberate'));
      } catch (e) {
        error.value = e;
      }
    });
    return { error };
  },
  template: '<div>{{ error?.message }}</div>',
});

const wrapper = mount(FailingComponent, {
  global: { config: { errorHandler: globalErrorHandler } },
});
await flushPromises();

// Error was handled internally — should NOT reach Vue's global errorHandler
expect(globalErrorHandler).not.toHaveBeenCalled();
// But it IS reflected in the template
expect(wrapper.text()).toBe('deliberate');

Edge Cases & Gotchas

  • defineAsyncComponent without <Suspense>: If you use defineAsyncComponent but do not wrap the usage in <Suspense>, a failed loader silently shows the errorComponent (if configured) but does not trigger onErrorCaptured. The error reaches app.config.errorHandler directly.
  • Watchers started before mount: A watch callback that fires immediately (via immediate: true) executes synchronously inside Vue’s setup pipeline and will reach onErrorCaptured. A watcher that fires asynchronously after mount runs outside that pipeline if the callback is async and unhandled.
  • onServerPrefetch in SSR: During server-side rendering, onServerPrefetch callbacks are awaited by Vue. Rejections from them reach app.config.errorHandler on the server, but the error object may differ from client-side equivalents.
  • Plugin-installed composables: Third-party plugins that call async APIs during app.use() installation run before your app.config.errorHandler is registered. Install your error handler before any app.use() calls.
  • Boundary registered after a top-level await: If a parent’s own setup() awaits before it calls onErrorCaptured, the hook is not yet installed when a fast-failing child inside its <Suspense> rejects. The rejection skips the local boundary and lands in app.config.errorHandler. Register onErrorCaptured synchronously at the very top of the parent’s setup(), ahead of any await.

FAQ

Why does app.config.errorHandler not fire for async errors in <script setup> top-level scope? <script setup> top-level await is only safe when the component is inside <Suspense>. Without <Suspense>, Vue receives a Promise from setup() but does not attach a rejection handler to it, so the rejection becomes an unhandledrejection event that bypasses Vue’s error pipeline entirely.

Can I make onErrorCaptured catch unhandledrejection events? No. onErrorCaptured only intercepts errors that Vue itself catches during component rendering, lifecycle hooks, and watcher callbacks. Browser unhandledrejection events originate outside the Vue runtime and must be handled with a separate window.addEventListener('unhandledrejection', …) listener.

Does wrapping a fetch call in onMounted make async errors safe? Partially. Vue wraps the onMounted lifecycle callback in its error pipeline, so a rejection that escapes the callback’s top-level await will reach app.config.errorHandler. However, nested Promises inside the callback that are not awaited still escape. Always use try/catch inside the async callback for complete coverage.

Should I disable unhandledrejection logging once Vue captures the error? Keep both listeners, but deduplicate. A well-behaved boundary returns false from onErrorCaptured to halt Vue-level propagation, while the window listener calls event.preventDefault() after logging. Because the two pipelines observe different sources — Vue’s render and lifecycle path versus orphaned browser rejections — the same underlying failure rarely reaches both. If you do see duplicate telemetry, tag each capture with its origin (vueInfo versus the unhandledrejection fallback string) and drop repeats by stack-trace fingerprint inside your SDK’s beforeSend hook, so a single fault produces exactly one tracked event.