This page details the reporting half of the strategy in Vue & Svelte Global Error Handlers; the recovery half lives in Handling Async Errors in Vue 3 with onErrorCaptured.
The exact problem #
A Vue 3 application has onErrorCaptured in three wrapper components and no global handler. Failures inside those subtrees render fallbacks correctly. Everything else — a watcher that throws on a malformed API response, a computed getter that divides by an undefined denominator, a component that fails during its very first render before any wrapper is mounted — produces a console message that no one sees and a partially rendered screen.
app.config.errorHandler is the catch-all for anything Vue itself invokes. Knowing precisely what that includes, and what it excludes, is what makes the difference between “we log errors” and “we know when the app breaks”.
Zero-to-working handler #
// main.ts
import { createApp, type ComponentPublicInstance } from 'vue';
import App from './App.vue';
const app = createApp(App);
// Register BEFORE mount so a failure in the first render is captured.
app.config.errorHandler = (err, instance, info) => {
reportCrash({
scope: 'vue',
where: componentChain(instance), // 'App > Dashboard > RevenueCard'
hook: info, // 'render function', 'watcher callback', …
name: err instanceof Error ? err.name : 'NonError',
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
markReported(err);
// Re-log in development so the browser's error overlay still fires.
if (import.meta.env.DEV) console.error(err);
};
/** Walk $parent to build a readable component path. */
function componentChain(instance: ComponentPublicInstance | null): string {
const names: string[] = [];
let node = instance;
let depth = 0;
while (node && depth < 12) {
const name = node.$options.name ?? node.$options.__name ?? 'Anonymous';
names.unshift(name);
node = node.$parent;
depth += 1;
}
return names.join(' > ') || 'unknown';
}
app.mount('#app');
The component chain is the Vue equivalent of React’s component stack, and it is the single most valuable field in the payload. Without it, every report says “TypeError in render function” and nothing about which render function — the grouping problem covered in Deduplicating Error Noise with Fingerprinting and Sampling.
Covering the gap #
// globalHandlers.ts
export function installWindowHandlers(): void {
window.addEventListener('error', (event) => {
if (wasReported(event.error)) return; // already handled by the Vue handler
// Cross-origin script errors arrive with no useful detail at all.
const opaque = event.message === 'Script error.' && !event.filename;
reportCrash({
scope: opaque ? 'third-party' : 'window',
message: event.message,
source: `${event.filename}:${event.lineno}:${event.colno}`,
stack: event.error instanceof Error ? event.error.stack : undefined,
});
});
window.addEventListener('unhandledrejection', (event) => {
if (wasReported(event.reason)) return;
reportCrash({
scope: 'unhandled-rejection',
message: event.reason instanceof Error ? event.reason.message : String(event.reason),
stack: event.reason instanceof Error ? event.reason.stack : undefined,
});
});
}
Deduplication matters more here than in React, because Vue’s handler and the window handler can both see the same failure depending on how it was thrown. The markReported/wasReported pair — a non-enumerable flag on the error object — keeps counts honest, the same technique used for escalation in Rethrowing Unrecoverable Errors to a Parent Boundary.
Reporting globally, recovering locally #
The global handler runs after Vue has already unwound the failed render. It cannot render a fallback, and it should not try. Recovery belongs to a wrapper component:
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue';
const props = defineProps<{ scope: string }>();
const failed = ref(false);
onErrorCaptured((err) => {
failed.value = true;
// Return false to stop propagation ONLY if this subtree fully owns the failure.
// Returning nothing lets the global handler still report it.
return undefined;
});
function retry() {
failed.value = false;
}
</script>
{{ scope }} is unavailable
The return value of onErrorCaptured is the subtle part. Returning false stops propagation, which also stops the global handler from ever seeing the error — convenient for expected failures, silently destructive if applied broadly.
| Return value | Propagation | Global handler sees it? | Use when |
|---|---|---|---|
undefined (nothing) |
Continues upward | Yes | Default: recover locally, report globally |
false |
Stops here | No | A failure you fully expect and handle |
Verification #
- Throw in a render function. Confirm one report with a component chain naming the failing component and
inforeadingrender function. - Throw in a watcher. Same error message, different
info; confirm the two produce different fingerprints so they can be triaged separately. - Reject an unawaited promise. Confirm it reaches the
unhandledrejectionhandler and not the Vue handler, proving the coverage split. - Throw the same error through both paths. Verify exactly one report, thanks to
markReported. - Check first-render coverage. Move the
errorHandlerassignment to aftermount()temporarily; a throw inApp.vueshould stop being reported — the reason registration order matters.
Development versus production behaviour #
The handler should behave differently in the two environments, and the difference is worth being deliberate about. In development, re-logging the original error after reporting keeps Vue’s overlay and the browser’s own stack navigation working — losing those in exchange for a tidy console is a bad trade while you are debugging.
In production the opposite applies: the console is not read by anyone, and re-logging costs a serialisation of the whole error on a device that may already be struggling. Reporting silently, with the console left alone, is the right default there.
The one thing that should not vary is the payload. A handler that attaches extra context only in development produces reports you cannot reproduce from, because the field you rely on while debugging is exactly the field missing from every real report.
Frequently asked questions #
Does app.config.errorHandler catch errors in async setup or event handlers?
It catches anything thrown inside Vue-managed execution: render functions, lifecycle hooks, watchers, computed getters, and event handlers Vue invokes. An unawaited promise rejection goes to window.onunhandledrejection instead.
What is the info argument actually useful for?
It names the lifecycle or subsystem that was running. Including it in the fingerprint separates a render-time failure from the same error thrown in a watcher — usually different causes and fixes.
Should the global handler show UI?
No. It does not know which part of the tree failed, and the render has already unwound. Report there; let a wrapper with onErrorCaptured own the visible recovery.
In short: register early, capture the component chain and the hook label, cover the gaps with window listeners, deduplicate across the two channels, and leave the visible recovery to a wrapper component that knows what the user was doing.
Related #
- Vue & Svelte Global Error Handlers — the cross-framework handler model
- Handling Async Errors in Vue 3 with onErrorCaptured — subtree-level recovery
- Error Telemetry & Crash Reporting — the payload these handlers feed