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 onErrorCaptured 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 Vue 3 setup() sync throw in setup() throw new Error(…) onErrorCaptured + errorHandler ✓ captured by Vue fire-and-forget promise fetchData() // no await unhandledrejection window event only ✗ escapes Vue pipeline async setup() + Suspense await fetch(…) // throws onErrorCaptured (parent of Suspense) ✓ captured if wrapped Without Suspense, async setup() errors in defineAsyncComponent also escape to unhandledrejection. Always wrap with try/catch and map failures to reactive state, or use Suspense for async setup.

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; });

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.

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:







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.

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.

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.