This page is part of Vue & Svelte Global Error Handlers, which covers framework-level interception strategies for Vue 3 and Svelte before reactive proxies or compiled component trees are corrupted.

The Exact Problem: What onErrorCaptured Does Not Catch #

onErrorCaptured is a lifecycle hook that fires synchronously within Vue’s rendering and reactivity pipeline. It receives (err, instance, info) — the thrown value, the component instance, and a string describing the lifecycle phase. It works for synchronous errors thrown during setup(), template rendering, watch callbacks, and onMounted/onUpdated hooks.

It does not work for unhandled promise rejections. When an async event handler rejects after the await, the rejection lands in the JavaScript microtask queue — outside Vue’s synchronous call stack. By the time the rejection surfaces, onErrorCaptured has already returned. The result is a silent unhandledrejection event that bypasses every component boundary.

The diagram below shows where synchronous and async errors diverge in Vue 3’s execution model:

Vue 3 error propagation: synchronous vs asynchronous paths Two parallel swimlanes showing how synchronous errors flow through onErrorCaptured while async rejections bypass it and reach the global unhandledrejection handler instead. Synchronous error Async rejection throw inside setup() onErrorCaptured fires return false → stop app.config.errorHandler propagates if not false async handler rejects microtask queue onErrorCaptured already returned — MISSED window: unhandledrejection → must catch here

The practical consequence: any async method bound to a @click handler, any composable that awaits without try/catch, and any nextTick callback that throws — all of these produce silent failures unless you explicitly bridge the gap.

Zero-to-Working Solution #

The composable below does three things in one unit: it registers onErrorCaptured for synchronous errors, wraps the unhandledrejection event for async escapes, and exposes a wrapAsync helper that catches at the call site and forwards into the same reactive error ref. Drop this into any parent component that owns a subtree you want to protect.

// composables/useErrorBoundary.ts
import { ref, onErrorCaptured, onMounted, onUnmounted } from 'vue';
import type { ComponentPublicInstance } from 'vue';

export interface CapturedError {
  err: unknown;
  source: 'sync' | 'async';
  info?: string;               // lifecycle phase string Vue provides
  instance?: ComponentPublicInstance | null;
  timestamp: number;
}

export function useErrorBoundary() {
  const error = ref<CapturedError | null>(null);
  const errorCount = ref(0);

  // — Synchronous boundary: fires for render/setup/lifecycle errors —
  onErrorCaptured((err, instance, info) => {
    error.value = { err, source: 'sync', info, instance, timestamp: Date.now() };
    errorCount.value++;
    // Return false: stop the error propagating to parent boundaries
    // and to app.config.errorHandler. Remove this line if you want
    // parent handlers to also run.
    return false;
  });

  // — Async safety net: catches promise rejections that bypass onErrorCaptured —
  const handleRejection = (event: PromiseRejectionEvent) => {
    // Only intercept if no boundary has already handled a synchronous error
    // in the same tick (avoids double-logging the same failure).
    if (error.value?.timestamp === event.timeStamp) return;
    error.value = { err: event.reason, source: 'async', timestamp: Date.now() };
    errorCount.value++;
    event.preventDefault(); // suppress the browser console warning
  };

  onMounted(() => window.addEventListener('unhandledrejection', handleRejection));
  onUnmounted(() => window.removeEventListener('unhandledrejection', handleRejection));

  // — wrapAsync: explicit try/catch at the call site, forwarded into error ref —
  // Use this for every @click handler or composable that awaits.
  // The caller never sees the rejection — it lands in error.value instead.
  async function wrapAsync<T>(fn: () => Promise<T>): Promise<T | undefined> {
    try {
      return await fn();
    } catch (err) {
      error.value = { err, source: 'async', timestamp: Date.now() };
      errorCount.value++;
      return undefined;
    }
  }

  const reset = () => {
    error.value = null;
  };

  return { error, errorCount, wrapAsync, reset };
}

Usage in a parent component that guards a data-fetching subtree:


<script setup lang="ts">
import { useErrorBoundary } from '@/composables/useErrorBoundary';
import DataWidget from './DataWidget.vue';

const { error, wrapAsync, reset } = useErrorBoundary();

const reload = () => wrapAsync(() => fetch('/api/data').then(r => r.json()));
</script>

Step-by-Step Walkthrough #

  1. onErrorCaptured with return false — The hook registers in setup() scope and runs synchronously whenever a descendant throws during render or a lifecycle method. Returning false stops the error from bubbling further up the component tree. If you omit the return value (or return anything other than false), Vue continues propagating to the next ancestor’s onErrorCaptured and then to app.config.errorHandler.

  2. handleRejection on unhandledrejection — This listener runs in the browser’s global scope and catches every promise rejection not handled by a .catch() or try/catch. Calling event.preventDefault() suppresses the default “Uncaught (in promise)” console error so your UI feedback (the v-if="error" block) becomes the only user-visible signal. Attach in onMounted and remove in onUnmounted to avoid listener leaks when the parent component is destroyed.

  3. wrapAsync at the call site — For @click handlers and composable functions that await, wrapAsync is the explicit bridge. It awaits the function, catches any rejection, and writes it into the shared error ref. This is preferable to relying solely on the global unhandledrejection listener because it preserves call-site context and avoids the race where the rejection fires after the component has already unmounted.

  4. errorCount for circuit-breaking — The counter lets callers implement a circuit breaker: if errorCount.value > threshold, disable the retry button or redirect rather than entering an infinite retry loop. Wire it into a computed alongside error to drive the UI state machine.

  5. reset() — Clears error.value so the parent’s v-if branch switches back to rendering the healthy subtree. Call it before issuing a retry so the error UI does not flash during the new request.

Edge Cases #

Scenario What goes wrong Mitigation
Error thrown inside nextTick() Executes after the current render cycle; onErrorCaptured has already returned and will not fire Wrap the nextTick callback body in try/catch and forward to error.value manually
Async composable with no try/catch Rejection escapes to unhandledrejection; if the component has already unmounted, the listener is gone Use wrapAsync at the composable call site, not inside the composable itself
Third-party SDK initialising outside the component tree Rejections arrive before the Vue app mounts; onMounted listener is not yet attached Register a one-time unhandledrejection handler in main.ts as a global fallback
AbortController signal ignored on unmount Background fetch completes and tries to mutate unmounted state via a closure Store controllers in a ref<AbortController[]>, call .abort() in onUnmounted, and check signal.aborted before any state write

The managing-state-reset-after-uncaught-promise-rejections pattern for clearing reactive state after a rejection without triggering secondary render errors is covered in Managing State Reset After Uncaught Promise Rejections.

Verification Steps #

DevTools console check — After triggering an async failure, confirm the browser console shows no “Uncaught (in promise)” warning. The event.preventDefault() in handleRejection suppresses it; if it still appears, the listener was not attached (the component mounted after the rejection fired).

Reactive ref check — In Vue DevTools, inspect the parent component’s state. error should be non-null with source: 'async' after a promise rejection, and null again after reset() is called.

Vitest assertion — The following harness confirms both the sync and async paths in isolation:

// useErrorBoundary.test.ts
import { defineComponent, nextTick, h } from 'vue';
import { mount } from '@vue/test-utils';
import { useErrorBoundary } from '@/composables/useErrorBoundary';
import { describe, it, expect } from 'vitest';

const makeWrapper = () =>
  defineComponent({
    setup() {
      const { error, wrapAsync, reset } = useErrorBoundary();
      return { error, wrapAsync, reset };
    },
    render() { return h('div'); },
  });

describe('useErrorBoundary', () => {
  it('captures async rejection via wrapAsync', async () => {
    const wrapper = mount(makeWrapper());
    const vm = wrapper.vm as any;

    await vm.wrapAsync(() => Promise.reject(new Error('fetch failed')));
    await nextTick();

    expect(vm.error).not.toBeNull();
    expect((vm.error.err as Error).message).toBe('fetch failed');
    expect(vm.error.source).toBe('async');

    vm.reset();
    expect(vm.error).toBeNull();
  });
});

Run with npx vitest run useErrorBoundary.test.ts. A passing test confirms wrapAsync routes rejections to error.value and reset() clears it.

Frequently Asked Questions #

Does onErrorCaptured intercept unhandled promise rejections in Vue 3? No. onErrorCaptured only fires for synchronous errors thrown inside the Vue rendering pipeline — setup(), lifecycle hooks, and synchronous event handlers. Async rejections surface as unhandledrejection browser events and must be caught with try/catch, .catch(), or a global listener. The wrapAsync helper in the composable above handles this at the call site.

How do I prevent infinite error loops when retrying after onErrorCaptured fires? Track error frequency via errorCount. After a threshold (typically 3), show a permanent failure state instead of a retry button. A circuit breaker composable drawing on Custom Hooks for Async Error Catching can enforce this across multiple components.

Can I mutate reactive state inside onErrorCaptured? Avoid synchronous state mutations that drive re-renders from inside the hook body — a failed render is what triggered the hook, and re-triggering it synchronously can cause recursive exceptions. Defer any reactive writes with nextTick(() => { stateRef.value = ... }).