Vue 3 Error Capturing and Fallback Strategies
Vue 3 ships with two distinct error interception layers — a global handler on the app instance and a per-component lifecycle hook — that together let you isolate failures without letting them cascade into a blank screen. This guide details both layers, their interaction with Suspense boundaries, and the fallback rendering patterns needed to keep a Vue app navigable after a component tree crashes. It assumes you already have a baseline understanding of runtime error interception from the Core JavaScript Error Handling & Boundaries reference, and it sits alongside sibling implementations like Implementing React Error Boundaries for Production and Mastering window.onerror and Global Event Listeners.
After working through this page you will be able to:
- Wire
app.config.errorHandlerto a telemetry pipeline without swallowing errors in production - Use
onErrorCapturedto build per-subtree boundaries that stop propagation selectively - Handle errors thrown inside
<Suspense>fallback and default slots - Design fallback components with reactive reset triggers
- Avoid the propagation-direction mistake that causes silent swallowing
Problem Framing & Symptom Identification
Vue 3 renders component trees synchronously during the initial mount and on every reactive update. When an error is thrown inside any Vue-managed context — the setup() function, a template expression, a watch callback, or a lifecycle hook such as onMounted — Vue catches it internally and routes it through its own error boundary pipeline before surfacing it to the browser.
Without explicit configuration two things happen: Vue logs the error to the console (in development) or suppresses it (in production when no handler is registered), and the component that threw is unmounted, leaving a blank region in the UI. The rest of the application tree keeps running, which is the correct isolation behaviour, but you lose telemetry and the user sees nothing useful.
Symptoms that indicate this is your problem:
- A section of the page disappears silently in production with no visible error
- Browser error monitoring reports far fewer errors than your Vue warnings in development
- The
infostring in your handler reads"setup function"or"watcher callback"— indicating Vue caught the error before it could reachwindow.onerror - Async composables throw but no
unhandledrejectionfires (Vue absorbed the rejection) - Errors from
watch(source, async handler)callbacks appear in telemetry but not inonErrorCaptured(Vue routes watcher errors directly toapp.config.errorHandler, skipping the component chain for watchers created outside the component that owns the boundary)
Understanding these routing differences before you instrument saves hours of misread dashboards. Vue’s error pipeline is not a single funnel — it has multiple entry points with different propagation rules depending on whether the error source is synchronous or asynchronous and whether it originates in the render tree or in a reactive side-effect.
Prerequisites & Environment Setup
# Vue 3.4+ recommended for Suspense stability
npm install vue@^3.4.0
# Telemetry SDK — substitute your platform
npm install @sentry/vue@^8.0.0
# Vite for building with hidden source maps
npm install --save-dev vite@^5.0.0 @vitejs/plugin-vue@^5.0.0
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
build: {
sourcemap: 'hidden', // Generates .map files without a sourceMappingURL comment
minify: 'terser',
rollupOptions: {
output: {
sourcemapFileNames: 'assets/[name]-[hash].js.map'
}
}
}
});
The 'hidden' sourcemap mode keeps .map artifacts out of the public bundle while making them available for secure CI/CD upload to your observability platform.
Step-by-Step Implementation
1. Register the Global Error Handler
app.config.errorHandler must be set before app.mount(). Vue ignores handler registration after mounting.
// main.js
import { createApp } from 'vue';
import App from './App.vue';
import { captureException } from './telemetry'; // your SDK wrapper
const app = createApp(App);
app.config.errorHandler = (err, instance, info) => {
// Extract a human-readable component name from the internal vnode
const component =
instance?.$options?.name ||
instance?.$.type?.name ||
instance?.$.type?.__name || // <script setup> infers __name from filename
'Anonymous';
captureException(err, {
vueInfo: info, // e.g. "setup function", "watcher callback", "v-on handler"
component,
url: window.location.href,
});
// In development let Vue's own overlay surface the full stack
if (import.meta.env.DEV) {
throw err;
}
// In production re-throw so Vue unmounts the broken subtree and
// any parent onErrorCaptured boundaries receive the error
};
app.mount('#app');
The info parameter is the most Vue-specific piece of metadata — it tells you not just that an error occurred but in which lifecycle phase, which is critical for distinguishing render failures from data-fetch failures.
2. Build a Composable Error Boundary
onErrorCaptured must be called inside a component’s setup() function. The idiomatic Vue 3 pattern is a composable that the wrapper component calls, keeping the boundary logic reusable across multiple wrapper components.
// composables/useErrorBoundary.js
import { ref, onErrorCaptured } from 'vue';
export function useErrorBoundary() {
const error = ref(null);
const info = ref('');
onErrorCaptured((err, _instance, vueInfo) => {
error.value = err;
info.value = vueInfo;
return false; // Stops propagation — parent onErrorCaptured and errorHandler are NOT called
});
function reset() {
error.value = null;
info.value = '';
}
return { error, info, reset };
}
Returning false is the stop signal. Returning any other value — including true, undefined, or nothing — lets the error continue propagating up the parent chain to the next onErrorCaptured and eventually to app.config.errorHandler.
3. Create the Boundary Wrapper Component
Something went wrong in this section.
The role="alert" ensures screen readers announce the fallback immediately. The scoped slot lets call sites inject a custom fallback UI while still relying on the boundary’s error capture logic:
4. Handle Errors Inside Suspense Boundaries
Vue’s <Suspense> is the mechanism for async component loading and async setup() functions. It has its own error state that onErrorCaptured from a parent of <Suspense> will capture; however, <Suspense> itself exposes an #fallback slot for the loading state and emits @error for explicit handling at the template level.
Widget failed to load: {{ error.message }}
console.warn('Suspense caught:', e)">
The @error event on <Suspense> fires for errors thrown by async setup functions inside its default slot. It does not replace onErrorCaptured — both fire, in the order: onErrorCaptured on parent components, then @error on the nearest <Suspense>, then app.config.errorHandler.
5. Wire Telemetry With Source Map Context
For production symbolication to work, your telemetry call must include the raw Error object (which carries the obfuscated stack) and the release identifier that matches the source-map upload.
// telemetry.js
import * as Sentry from '@sentry/vue';
export function initTelemetry(app) {
Sentry.init({
app,
dsn: import.meta.env.VITE_SENTRY_DSN,
release: import.meta.env.VITE_RELEASE_ID, // injected by CI during build
environment: import.meta.env.MODE,
integrations: [Sentry.browserTracingIntegration()],
tracesSampleRate: 0.2,
beforeSend(event) {
// Strip any PII from breadcrumb messages before transmission
event.breadcrumbs?.values?.forEach(b => {
if (b.message) b.message = b.message.replace(/email=[^\s&]+/gi, 'email=[REDACTED]');
});
return event;
},
});
}
export function captureException(err, context) {
Sentry.withScope(scope => {
scope.setExtras(context);
Sentry.captureException(err);
});
}
Production Telemetry Integration
The info string Vue passes to both onErrorCaptured and app.config.errorHandler maps directly to Vue’s internal lifecycle identifiers. Recording it as a searchable tag in your observability platform lets you build dashboards that break down errors by lifecycle phase — separating render failures ("render function") from data loading failures ("setup function") from event handler failures ("v-on handler"). Without this tag, you cannot distinguish a component that crashes during initial data fetch from one that crashes because a parent passed a malformed prop.
Tag it explicitly:
captureException(err, {
vueInfo: info, // becomes a searchable tag: vueInfo:"setup function"
component,
route: router.currentRoute.value.fullPath,
});
If you are using Vue Router, attach a navigation guard to capture route-level errors as well:
router.onError((err, to) => {
captureException(err, { route: to.fullPath, vueInfo: 'router navigation' });
});
Mapping info values to alert severity
Not every info value warrants a high-severity alert. A "v-on handler" error may be a user-triggered edge case, while a "render function" error indicates a broken template that every user will hit. Use this mapping as a starting point:
info value |
Typical cause | Recommended alert level |
|---|---|---|
"render function" |
Undefined prop, broken computed result | Critical — fires on every render |
"setup function" |
Missing dependency, failed sync init | High — fires on component mount |
"watcher callback" |
Reactive side-effect error | Medium — may be intermittent |
"v-on handler" |
User interaction edge case | Low — triggered by specific action |
"component hook" |
onMounted, onUpdated failure |
Medium — depends on frequency |
"app:errorCaptured hook" |
Error in a plugin’s mixin | High — affects all instances |
Record the info value as a primary dimension in your telemetry platform, not just as an extra field. This lets you alert on a spike in "render function" errors (which signal broken deployments) independently of a spike in "v-on handler" errors (which may signal a specific UI interaction bug).
Integrating with Vue’s devtools bridge
During development, Vue DevTools provides an error panel that surfaces onErrorCaptured events in the component inspector. To make boundary components visible in the panel, give your wrapper component an explicit name:
// composables/useErrorBoundary.js — add name via defineOptions in the wrapper
// (in the component that calls this composable)
// <script setup>
// defineOptions({ name: 'ErrorBoundary' });
In production, the DevTools bridge is inactive. All diagnostic insight must come from your telemetry SDK. Attach the component name, Vue info string, and current route to every captured event to preserve the diagnostic value that DevTools provides locally.
Source map upload for Vue + Vite
The captureException call sends the raw (minified) Error object. Your observability platform symbolicates it server-side using source maps uploaded during CI/CD. Without matching maps, the component name and file path in the stack trace will reference mangled identifiers.
# .github/workflows/deploy.yml — upload source maps after build
- name: Build Vue app
run: npm run build
- name: Upload source maps to Sentry
run: |
npx sentry-cli sourcemaps upload \
--release "${{ env.VITE_RELEASE_ID }}" \
--dist "${{ github.sha }}" \
./dist/assets/
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: your-org
SENTRY_PROJECT: your-vue-project
The --dist flag correlates uploaded maps to the specific build artifact. Without it, Sentry may pick the wrong map version when multiple deployments are active simultaneously. Delete the .map files from the public-facing dist/assets/ directory after upload if you are not using build.sourcemap: 'hidden' — serving them publicly exposes your unminified source.
Verification & Testing
Unit-test the composable in isolation using @vue/test-utils:
// useErrorBoundary.test.js
import { mount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
import { useErrorBoundary } from './useErrorBoundary';
const BoundaryWrapper = defineComponent({
setup() {
const { error, reset } = useErrorBoundary();
return () => h('div', error.value ? error.value.message : 'ok');
},
});
const ThrowingChild = defineComponent({
setup() { throw new Error('deliberate test error'); }
});
it('captures child errors and stops propagation', async () => {
const globalHandler = vi.fn();
const wrapper = mount(
{ render: () => h(BoundaryWrapper, null, { default: () => h(ThrowingChild) }) },
{ global: { config: { errorHandler: globalHandler } } }
);
// onErrorCaptured returned false → global handler was not called
expect(globalHandler).not.toHaveBeenCalled();
expect(wrapper.text()).not.toBe('ok'); // fallback rendered
});
Test the reset path explicitly — a boundary that cannot recover after reset is as broken as one that never captured the error:
it('resets and remounts child after reset() is called', async () => {
let shouldThrow = true;
const ToggleChild = defineComponent({
setup() {
if (shouldThrow) throw new Error('first render fails');
return () => h('div', 'recovered');
},
});
const wrapper = mount(
{ render: () => h(BoundaryWrapper, null, { default: () => h(ToggleChild) }) },
{ global: { config: { errorHandler: vi.fn() } } }
);
// Trigger reset — boundary should attempt to re-render child
shouldThrow = false;
// The reset function sets error.value = null, triggering re-render
// Use the exposed composable via the wrapper's vm or a test ref
// In practice, drive reset via a button click in the fallback slot
await wrapper.find('button').trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.text()).toBe('recovered');
});
For end-to-end verification in a staging environment, use a synthetic error injection endpoint that the Vue app calls on a specific route, confirming the full pipeline: onErrorCaptured fires → captureException runs → source map upload resolves the stack → alert fires in your observability platform.
Failure Modes & Edge Cases
| Scenario | Root Cause | Fix |
|---|---|---|
app.config.errorHandler never fires |
Handler registered after app.mount() |
Move registration before app.mount('#app') |
onErrorCaptured fires but error still reaches global handler |
Forgot to return false |
Always return false explicitly when you want to stop propagation |
Async setup() error not captured by onErrorCaptured |
Promise rejection escapes Vue’s async wrapper | Wrap async operations in try/catch; see handling async errors in Vue 3 Composition API setup |
<Suspense> error not surfaced to parent boundary |
@error listener missing; async component swallows rejection |
Add @error handler on <Suspense> and ensure loader rejects cleanly |
Component name shows as "Anonymous" in telemetry |
<script setup> without defineOptions({ name }) or filename inference disabled |
Add defineOptions({ name: 'MyComponent' }) inside <script setup> |
Re-throw in app.config.errorHandler crashes the handler |
Secondary error inside the handler itself | Guard the handler body with a top-level try/catch around the telemetry call |
FAQ
What is the difference between app.config.errorHandler and onErrorCaptured?
app.config.errorHandler is a single global catch-all registered once on the app instance. onErrorCaptured is a per-component hook that can be added to any ancestor in the component tree. The component-level hook fires first; if it returns false, the global handler is never called for that error.
Does returning true from onErrorCaptured stop propagation?
No. Only returning the boolean false stops propagation. Any other return value — including true, an object, or nothing — allows the error to continue up the chain. This is a common source of accidental silent swallowing when developers expect React’s true-stops-propagation convention.
Will app.config.errorHandler catch errors in router.beforeEach guards?
No. Vue Router executes navigation guards outside Vue’s component rendering pipeline, so errors there do not pass through Vue’s error handler. Use router.onError() to register a separate handler for navigation failures.
How do I recover from a Suspense async setup error without a full page reload?
Reset the onErrorCaptured state (set error.value = null) and use a :key binding on the <Suspense> wrapper to force Vue to unmount and remount the async subtree. A changed :key triggers a complete re-setup of all child components.