Error Boundaries and Global Error Handling: onErrorCaptured Propagation and Monitoring
Chapter 28: Error Boundaries and Global Error Handling — The onErrorCaptured Propagation Chain and Monitoring Integration
Vue 3's error propagation system has a counterintuitive design: returning
falsefromonErrorCapturedstops propagation, while not returning (returningundefined) continues propagation — the exact opposite of what you'd expect from DOM event'sstopPropagationlogic. This is one of the most common bug categories in Vue error handling code.
The central question of this chapter: When an error occurs somewhere in a component tree, how does Vue propagate it upward, and how can errors be intercepted and handled at different levels?
After reading this chapter you will understand:
- The
onErrorCapturedpropagation chain mechanism, and how return values control whether errors continue propagating - All possible values of the
infoparameter, for pinpointing exactly which lifecycle phase produced an error - How to implement a React-style ErrorBoundary with fallback UI, and the blind spots in async error capture
Level 1 · What You Need to Know (1–3 Years Experience)
1.1 Where Errors Come From: What Vue Captures Automatically
Vue 3 automatically captures errors from these locations:
Synchronous errors (Vue captures directly):
- Synchronous errors in
setup()functions - Errors in lifecycle hooks (
onMounted,onUpdated, etc.) - Errors in render functions and templates
- Errors in
watch/watchEffectcallbacks - Errors in
v-onevent handlers
Async errors (captured conditionally):
async/awaiterrors insidesetup()(Vue captures these)- Errors inside
onMounted(async () => { ... })(Vue captures these)
Errors Vue cannot capture (must handle manually):
- Standalone Promises (not
awaited):setTimeout(async () => { ... }) - Errors in
setIntervalcallbacks - Async callbacks from third-party libraries
1.2 Three-Layer Error Handling
Vue 3 provides three levels of error handling, forming a system from fine-grained to global:
First layer: onErrorCaptured (component level)
<!-- ErrorBoundary.vue -->
<script setup>
import { onErrorCaptured, ref } from 'vue';
const error = ref(null);
onErrorCaptured((err, instance, info) => {
error.value = err;
console.log('Error captured:', err.message);
console.log('Component where error occurred:', instance);
console.log('Lifecycle phase:', info); // 'setup function', 'render function', etc.
return false; // Stop the error from propagating further up
});
</script>
<template>
<div v-if="error" class="error-ui">
Something went wrong: {{ error.message }}
</div>
<slot v-else />
</template>
Second layer: app.config.errorHandler (application-level global fallback)
const app = createApp(App);
app.config.errorHandler = (err, instance, info) => {
// Catches all errors not intercepted by onErrorCaptured
console.error('Global error handler:', err);
// Report to monitoring system
reportToMonitoring(err, instance, info);
};
Third layer: window.addEventListener('unhandledrejection') (catches async errors Vue can't reach)
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection:', event.reason);
reportToMonitoring(event.reason);
});
1.3 The info Parameter: Pinpointing Where the Error Happened
The third parameter info in both onErrorCaptured and app.config.errorHandler is a string describing which phase of the component lifecycle produced the error:
| info value | Meaning |
|---|---|
'setup function' |
Inside setup() |
'render function' |
During render function / template rendering |
'component mounted hook' |
Inside onMounted hook |
'component updated hook' |
Inside onUpdated hook |
'component unmounted hook' |
Inside onUnmounted hook |
'watcher callback' |
Inside watch/watchEffect callback |
'native event handler' |
Inside a native DOM event handler |
'component event handler' |
Inside a component custom event handler |
'directive hook' |
Inside a custom directive hook function |
'app unmount cleanup function' |
During app.unmount() |
In production error monitoring, info is highly valuable diagnostic information:
app.config.errorHandler = (err, instance, info) => {
Sentry.captureException(err, {
extra: {
componentName: instance?.type?.name || 'Unknown',
errorInfo: info, // 'setup function', 'render function', etc.
propsData: instance?.props,
}
});
};
1.4 warnHandler: Development-Mode Warning Handling
In addition to errorHandler, Vue provides warnHandler for intercepting Vue warnings in development mode:
app.config.warnHandler = (msg, instance, trace) => {
// msg: warning message string
// trace: component hierarchy trace
// Only show warnings that aren't known noise from third-party libraries
if (!msg.includes('Some Known Warning')) {
console.warn(msg, trace);
}
};
Note: warnHandler only takes effect in development builds (NODE_ENV !== 'production'). Vue does not emit warnings in production builds.
1.5 Implementing an Error Boundary Component
Similar to React's ErrorBoundary, Vue can implement fallback UI using onErrorCaptured:
<!-- components/ErrorBoundary.vue -->
<script setup>
import { onErrorCaptured, ref } from 'vue';
const props = defineProps({
fallback: String, // fallback text
});
const hasError = ref(false);
const errorMessage = ref('');
onErrorCaptured((err, instance, info) => {
hasError.value = true;
errorMessage.value = err.message;
// Return false to stop propagation to parent components
return false;
});
function retry() {
hasError.value = false;
errorMessage.value = '';
}
</script>
<template>
<div v-if="hasError" class="error-boundary">
<p>{{ props.fallback || 'This module is temporarily unavailable' }}</p>
<p class="error-detail">{{ errorMessage }}</p>
<button @click="retry">Retry</button>
</div>
<slot v-else />
</template>
Usage:
<ErrorBoundary fallback="Comment section failed to load">
<CommentSection />
</ErrorBoundary>
<ErrorBoundary fallback="Recommendations temporarily unavailable">
<RecommendList />
</ErrorBoundary>
Level 2 · How It Actually Works (3–5 Years Experience)
2.1 The Complete Error Propagation Path
When an error occurs inside a component, Vue's processing flow is:
Component C (error occurs)
│
│ Vue captures error (via try-catch wrapping hooks/render functions)
▼
┌─────────────────────────────────────┐
│ Call onErrorCaptured on C │
│ Returns false → stop propagation │
│ Returns anything else → continue │
└────────────────┬────────────────────┘
│ continue propagating
▼
┌─────────────────────────────────────┐
│ Call onErrorCaptured on parent B │
│ Returns false → stop propagation │
│ Returns anything else → continue │
└────────────────┬────────────────────┘
│ continue propagating
▼
┌─────────────────────────────────────┐
│ Call onErrorCaptured on ancestor A │
│ Returns false → stop propagation │
│ Returns anything else → continue │
└────────────────┬────────────────────┘
│ reached root, still not intercepted
▼
┌─────────────────────────────────────┐
│ app.config.errorHandler │
│ Global fallback — log it here │
└─────────────────────────────────────┘
Key details:
onErrorCapturedcaptures errors from child component trees, not from the current component itself- If
onErrorCaptureditself throws an error, both the new error and the original error propagate upward - Error propagation is synchronous, moving up level by level until intercepted or reaching the global handler
2.2 Multiple onErrorCaptured Hooks on the Same Component
A single component can register multiple onErrorCaptured hooks (common when using multiple composables):
// Registered in composable A
onErrorCaptured((err) => {
logToService(err);
// No return false — propagation continues
});
// Registered in composable B (same component)
onErrorCaptured((err) => {
showNotification(err.message);
return false; // This hook stops propagation
});
Multiple onErrorCaptured hooks execute in registration order (first registered, first executed). If any one of them returns false, the error stops propagating.
2.3 Async Error Capture Boundaries
The prerequisite for Vue capturing an async error is that the Promise must be awaited by Vue's hook system.
<script setup>
// Case 1: Vue captures this ✓
// Top-level async/await in setup is wrapped by Vue's internal try-catch
const data = await fetch('/api/data').then(r => r.json());
// Case 2: Vue captures this ✓
// The async callback of onMounted is wrapped by Vue's internal try-catch
onMounted(async () => {
const result = await fetch('/api/data');
// Errors here are captured by onErrorCaptured
});
// Case 3: Vue does NOT capture this ✗
// Standalone Promise chain — Vue doesn't know it exists
setTimeout(async () => {
const result = await fetch('/api/data'); // Error here won't be caught by onErrorCaptured
}, 1000);
// Case 4: Vue does NOT capture this ✗
// Unawaited Promise
fetch('/api/data').then(r => {
if (!r.ok) throw new Error('Request failed'); // Won't be caught by onErrorCaptured
});
</script>
Solution for cases 3 and 4:
// Globally catch unhandledrejection
window.addEventListener('unhandledrejection', (event) => {
// event.promise: the rejected Promise
// event.reason: the rejection reason (error object)
Sentry.captureException(event.reason, {
extra: { type: 'unhandledrejection' }
});
// Prevent the default console warning
event.preventDefault();
});
2.4 Complete Sentry Integration
The standard pattern for integrating Sentry in production:
// main.js
import { createApp } from 'vue';
import * as Sentry from '@sentry/vue';
import App from './App.vue';
import router from './router';
const app = createApp(App);
// Sentry initialization (must happen before app.use(router))
Sentry.init({
app,
dsn: 'https://[email protected]/project-id',
// Vue Router integration for tracking navigation performance
integrations: [
new Sentry.BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(router),
}),
],
// Sample rate: never use 100% in production
tracesSampleRate: 0.1,
// Environment label
environment: import.meta.env.MODE,
});
// Custom error handler (Sentry.init auto-sets errorHandler, but you can extend it)
const originalErrorHandler = app.config.errorHandler;
app.config.errorHandler = (err, instance, info) => {
Sentry.withScope((scope) => {
scope.setTag('error_info', info);
scope.setContext('vue_component', {
name: instance?.type?.name,
props: instance?.props,
});
Sentry.captureException(err);
});
if (originalErrorHandler) {
originalErrorHandler(err, instance, info);
}
};
app.use(router);
app.mount('#app');
Error handling in Vue Router navigation guards:
router.onError((error, to, from) => {
// Fires when a route's component fails to load
if (error.message.includes('Failed to fetch dynamically imported module')) {
// Dynamic import failure due to network error — reload the page
window.location.href = to.fullPath;
} else {
Sentry.captureException(error, {
extra: { to: to.fullPath, from: from.fullPath }
});
}
});
2.5 Error Severity Classification Strategy
app.config.errorHandler = (err, instance, info) => {
const severity = classifyError(err, info);
switch (severity) {
case 'critical':
// Render errors, risk of data loss: report immediately + notify user
Sentry.captureException(err, { level: 'fatal' });
showGlobalErrorAlert();
break;
case 'warning':
// Non-critical feature failure: report but don't disturb the user
Sentry.captureException(err, { level: 'warning' });
break;
case 'info':
// Expected errors (e.g. network timeout): log only
console.warn('Expected error:', err.message);
break;
}
};
function classifyError(err, info) {
if (info === 'render function') return 'critical';
if (err instanceof NetworkError) return 'info';
return 'warning';
}
Level 3 · Design Documents and Source Code (Senior Developers)
3.1 Vue's Error Handling Source Code
Vue 3's error handling core lives in packages/runtime-core/src/errorHandling.ts:
// Core function: callWithErrorHandling
// All potentially-failing calls inside Vue go through this function
export function callWithErrorHandling(
fn: Function,
instance: ComponentInternalInstance | null,
type: ErrorTypes,
args?: unknown[]
) {
let res;
try {
res = args ? fn(...args) : fn();
} catch (err) {
handleError(err, instance, type);
}
return res;
}
// Async version
export function callWithAsyncErrorHandling(
fn: Function | Function[],
instance: ComponentInternalInstance | null,
type: ErrorTypes,
args?: unknown[]
): any[] {
const res = callWithErrorHandling(fn, instance, type, args);
if (res && isPromise(res)) {
res.catch(err => {
handleError(err, instance, type);
});
}
return res;
}
The handleError function (the core of error propagation):
export function handleError(
err: unknown,
instance: ComponentInternalInstance | null,
info: string,
throwInDev = true
) {
if (instance) {
let cur = instance.parent;
// Walk the component tree, looking for errorCaptured hooks level by level
while (cur) {
const errorCapturedHooks = cur.ec; // array of errorCaptured hooks
if (errorCapturedHooks) {
for (let i = 0; i < errorCapturedHooks.length; i++) {
if (errorCapturedHooks[i](err, instance, info) === false) {
// Returns false → stop propagation
return;
}
}
}
cur = cur.parent;
}
// Check app.config.errorHandler (application-level handler)
const appContext = instance.appContext;
const globalErrorHandler = appContext.config.errorHandler;
if (globalErrorHandler) {
callWithErrorHandling(
globalErrorHandler,
null,
ErrorCodes.APP_ERROR_HANDLER,
[err, instance.proxy, info]
);
return;
}
}
// No handlers found — throw to the browser console
logError(err, info, contextVNode, throwInDev);
}
ErrorCodes enum (corresponding to all possible info values):
// packages/runtime-core/src/errorHandling.ts
export const enum ErrorCodes {
SETUP_FUNCTION, // 'setup function'
RENDER_FUNCTION, // 'render function'
WATCH_GETTER, // 'watcher getter'
WATCH_CALLBACK, // 'watcher callback'
WATCH_CLEANUP, // 'watcher cleanup function'
NATIVE_EVENT_HANDLER, // 'native event handler'
COMPONENT_EVENT_HANDLER, // 'component event handler'
VNODE_HOOK, // 'vnode hook'
DIRECTIVE_HOOK, // 'directive hook'
TRANSITION_HOOK, // 'transition hook'
APP_ERROR_HANDLER, // 'app errorHandler'
APP_WARN_HANDLER, // 'app warnHandler'
FUNCTION_REF, // 'ref function'
ASYNC_COMPONENT_LOADER, // 'async component loader'
SCHEDULER, // 'scheduler flush'
}
3.2 How Lifecycle Hooks Are Wrapped for Error Handling
All lifecycle hooks are wrapped in callWithErrorHandling when invoked:
// packages/runtime-core/src/componentOptions.ts (simplified)
function callHook(
hook: Function | Function[],
instance: ComponentInternalInstance,
type: LifecycleHooks,
arg?: any
) {
const hooks = isArray(hook) ? hook : [hook];
for (let i = 0; i < hooks.length; i++) {
callWithAsyncErrorHandling(hooks[i], instance, type, arg ? [arg] : undefined);
}
}
This explains why errors in onMounted(async () => { ... }) are captured: callWithAsyncErrorHandling calls .catch() on the returned Promise.
3.3 defineAsyncComponent Error Handling
Async component load failures are a common error scenario in production:
import { defineAsyncComponent } from 'vue';
const AsyncComp = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
// Component to show while loading
loadingComponent: LoadingSpinner,
// Component to show on load failure
errorComponent: ErrorDisplay,
// Delay before showing loadingComponent (milliseconds)
delay: 200,
// Timeout (milliseconds) — shows errorComponent after this
timeout: 3000,
// Custom load failure handling
onError(error, retry, fail, attempts) {
if (error.message.match(/fetch/) && attempts <= 3) {
// Network error — retry up to 3 times
retry();
} else {
fail();
}
}
});
Level 4 · Edge Cases and Traps (For Everyone)
Trap 1: The Counterintuitive Return Value of onErrorCaptured
Wrong mental model: Many developers think "not returning (returning undefined)" means "I handled the error," similar to how DOM events work by default:
// Wrong: assuming no return statement means the error is handled
onErrorCaptured((err) => {
logError(err);
// No return statement — developer assumes error is intercepted
// Reality: the error CONTINUES to propagate upward!
});
Correct understanding: In Vue's design:
- Return
false= stop propagation (actively intercept) - No return / return
true/ return any non-falsevalue = continue propagation
// Correct: explicitly stop propagation
onErrorCaptured((err) => {
logError(err);
return false; // Explicit false return
});
Reproducible scenario:
<!-- Parent.vue -->
<script setup>
import { onErrorCaptured } from 'vue';
onErrorCaptured((err) => {
console.log('Parent also captured the error!'); // This runs because Child didn't return false
});
</script>
<!-- Child.vue -->
<script setup>
import { onErrorCaptured } from 'vue';
onErrorCaptured((err) => {
console.log('Child captured the error'); // Runs first
// No return false → error continues to Parent
});
</script>
Trap 2: Standalone Promise Errors Are Not Captured by onErrorCaptured
Broken code:
<script setup>
import { onErrorCaptured, onMounted } from 'vue';
onErrorCaptured((err) => {
console.log('Error captured');
return false;
});
onMounted(() => {
// Problem: this Promise is not awaited — Vue doesn't know it exists
fetch('/api/data')
.then(r => r.json())
.then(data => {
processData(data); // If this throws an error...
});
// No .catch()!
});
</script>
Symptom: onErrorCaptured never triggers; the console shows "Uncaught (in promise) Error."
Correct code:
onMounted(async () => {
// Option 1: use async/await (Vue can capture these)
const r = await fetch('/api/data');
const data = await r.json();
processData(data); // Errors here are captured by Vue
});
// Option 2: global unhandledrejection handler
window.addEventListener('unhandledrejection', (event) => {
handlePromiseError(event.reason);
});
Trap 3: Mutating Reactive Data in onErrorCaptured Can Cause Infinite Loops
Broken code:
<script setup>
import { onErrorCaptured, ref, watch } from 'vue';
const count = ref(0);
const error = ref(null);
// watcher that may throw when count changes
watch(count, () => {
if (count.value > 10) {
throw new Error('Count too high');
}
});
onErrorCaptured((err) => {
error.value = err;
count.value = 0; // Wrong! Mutating count triggers the watcher again → may throw again
return false;
});
</script>
Correct approach: Only mutate state related to error display inside onErrorCaptured:
onErrorCaptured((err) => {
// Only set error display state
error.value = err.message;
// Don't touch data that could retrigger errors
return false;
});
// Handle reset logic separately
function reset() {
error.value = null;
count.value = 0;
}
Trap 4: Async Component Load Failures May Not Propagate to Parent onErrorCaptured
Broken scenario:
<script setup>
import { defineAsyncComponent, onErrorCaptured, ref } from 'vue';
const error = ref(null);
// Expecting to catch async component load failures
onErrorCaptured((err) => {
error.value = err;
return false;
});
const AsyncComp = defineAsyncComponent(() => import('./NonExistent.vue'));
</script>
<template>
<!-- onErrorCaptured may NOT catch errors here! -->
<AsyncComp />
</template>
Why: When defineAsyncComponent fails to load, by default it renders the errorComponent (if configured). The error does not necessarily propagate to the parent's onErrorCaptured.
Correct approach: Use the onError config to handle load failures:
const AsyncComp = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
retry();
} else {
fail();
// Manually rethrow if needed for upward propagation
throw error;
}
}
});
Trap 5: Throwing Errors in app.config.errorHandler Causes Original Error to Be Lost
Broken code:
app.config.errorHandler = (err, instance, info) => {
// If Sentry.captureException itself throws an error...
Sentry.captureException(err); // Suppose Sentry isn't initialized — this throws
// The original error err is now overshadowed by the new error from Sentry
};
Correct approach: Wrap all operations in the global error handler with try-catch:
app.config.errorHandler = (err, instance, info) => {
try {
Sentry.captureException(err, {
extra: { info, component: instance?.type?.name }
});
} catch (reportingError) {
// Fallback when reporting fails: at least log to console
console.error('Error reporting failed:', reportingError);
console.error('Original error:', err);
}
};
Chapter Summary
-
Returning false is what stops propagation: Not returning anything from
onErrorCaptured(returningundefined) means the error continues to bubble upward — the opposite of what most event systems do by default. You must explicitly returnfalseto intercept an error. -
Three defensive layers, each with its role:
onErrorCapturedhandles fine-grained component-level interception;app.config.errorHandleris the application-level global fallback;window.addEventListener('unhandledrejection')catches standalone Promise errors that Vue cannot reach. -
The info parameter is a debugging gold mine: Values like
'setup function','render function', and'watcher callback'pinpoint exactly where the error occurred. Including this in Sentry reports dramatically reduces investigation time. -
The capture boundary between async/await and standalone Promises: Errors in
onMounted(async () => { ... })are captured by Vue, but errors in unawaited Promises insideonMounted(() => { somePromise.then(...) })are not — this is the most common async error handling blind spot. -
Wrap Sentry calls in try-catch inside app.config.errorHandler: The error handler is the last line of defense. If it also throws an error, the original error information is lost, making debugging extremely difficult. All reporting calls should have a try-catch fallback.