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.

What the Vue handler covers and what it misses Two grouped columns. The left column, labelled reaches app.config.errorHandler, lists render function, lifecycle hooks, watcher callbacks, computed getters and Vue-invoked event handlers. The right column, labelled only reaches window handlers, lists unawaited promise rejections, timer callbacks, third-party script errors and code outside any component. reaches app.config.errorHandler · render function throws · lifecycle hooks (mounted, updated…) · watcher callbacks · computed getters · event handlers Vue invokes instance + info give you the component chain only reaches window handlers · unawaited promise rejections · setTimeout / setInterval callbacks · listeners added with addEventListener · third-party script failures · module-level code outside components no instance, no hook info — context must be added by hand

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>

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
Propagation from onErrorCaptured to the global handler A component box leads to an onErrorCaptured box, which branches two ways. Returning nothing continues to the global error handler, which reports the crash. Returning false stops at a terminator marked no global report. component throws onErrorCaptured renders the fallback returns nothing returns false app.config.errorHandler reports with component chain propagation stops no global report — use sparingly One failure, one report An error travels to the Vue handler, which reports it and marks it. The same error then reaches the window handler, which checks the mark and skips. A counter box shows the total reports as one. error thrown app.config.errorHandler reports + markReported() window handler sees the mark, skips total reports sent: 1 — the flag is non-enumerable so it never reaches the payload remove the mark and the same crash inflates every impact metric by two

Verification #

  1. Throw in a render function. Confirm one report with a component chain naming the failing component and info reading render function.
  2. Throw in a watcher. Same error message, different info; confirm the two produce different fingerprints so they can be triaged separately.
  3. Reject an unawaited promise. Confirm it reaches the unhandledrejection handler and not the Vue handler, proving the coverage split.
  4. Throw the same error through both paths. Verify exactly one report, thanks to markReported.
  5. Check first-render coverage. Move the errorHandler assignment to after mount() temporarily; a throw in App.vue should 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.