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:
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 #
-
onErrorCapturedwithreturn false— The hook registers insetup()scope and runs synchronously whenever a descendant throws during render or a lifecycle method. Returningfalsestops the error from bubbling further up the component tree. If you omit the return value (or return anything other thanfalse), Vue continues propagating to the next ancestor’sonErrorCapturedand then toapp.config.errorHandler. -
handleRejectiononunhandledrejection— This listener runs in the browser’s global scope and catches every promise rejection not handled by a.catch()ortry/catch. Callingevent.preventDefault()suppresses the default “Uncaught (in promise)” console error so your UI feedback (thev-if="error"block) becomes the only user-visible signal. Attach inonMountedand remove inonUnmountedto avoid listener leaks when the parent component is destroyed. -
wrapAsyncat the call site — For@clickhandlers and composable functions thatawait,wrapAsyncis the explicit bridge. Itawaits the function, catches any rejection, and writes it into the sharederrorref. This is preferable to relying solely on the globalunhandledrejectionlistener because it preserves call-site context and avoids the race where the rejection fires after the component has already unmounted. -
errorCountfor circuit-breaking — The counter lets callers implement a circuit breaker: iferrorCount.value > threshold, disable the retry button or redirect rather than entering an infinite retry loop. Wire it into acomputedalongsideerrorto drive the UI state machine. -
reset()— Clearserror.valueso the parent’sv-ifbranch 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 = ... }).
Related #
- Vue & Svelte Global Error Handlers — parent: app-level
app.config.errorHandlerand Svelte’s<svelte:window on:error>patterns - Custom Hooks for Async Error Catching — framework-agnostic composable patterns for normalising async errors across data-fetching layers
- Managing State Reset After Uncaught Promise Rejections — safe reactive state teardown after a failed async operation