Chapter 28

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 false from onErrorCaptured stops propagation, while not returning (returning undefined) continues propagation โ€” the exact opposite of what you'd expect from DOM event's stopPropagation logic. 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:


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):

Async errors (captured conditionally):

Errors Vue cannot capture (must handle manually):

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:

  1. onErrorCaptured captures errors from child component trees, not from the current component itself
  2. If onErrorCaptured itself throws an error, both the new error and the original error propagate upward
  3. 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:

// 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

  1. Returning false is what stops propagation: Not returning anything from onErrorCaptured (returning undefined) means the error continues to bubble upward โ€” the opposite of what most event systems do by default. You must explicitly return false to intercept an error.

  2. Three defensive layers, each with its role: onErrorCaptured handles fine-grained component-level interception; app.config.errorHandler is the application-level global fallback; window.addEventListener('unhandledrejection') catches standalone Promise errors that Vue cannot reach.

  3. 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.

  4. The capture boundary between async/await and standalone Promises: Errors in onMounted(async () => { ... }) are captured by Vue, but errors in unawaited Promises inside onMounted(() => { somePromise.then(...) }) are not โ€” this is the most common async error handling blind spot.

  5. 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.

Rate this chapter
4.5  / 5  (3 ratings)

๐Ÿ’ฌ Comments