Skip to main content

Error handling in Vue.js

Error handling in Vue.js catches runtime exceptions thrown inside components and prevents them from crashing the whole app.

Theory

TL;DR

  • onErrorCaptured in a parent component intercepts sync errors from any descendant during render, setup, or watch
  • Think of it as a firebreak: the error hits the boundary and stops spreading to the rest of the UI
  • Returning false inside the hook prevents the error from bubbling further up the component tree
  • It only catches sync throws. Async functions and Promise rejections need try/catch
  • app.config.errorHandler acts as the global last resort for anything local boundaries missed

Quick example

vue
<!-- ErrorBoundary.vue --> <script setup> import { ref, onErrorCaptured } from 'vue' const hasError = ref(false) const errorMsg = ref('') onErrorCaptured((err, instance, info) => { hasError.value = true errorMsg.value = err.message // e.g., "Failed to parse JSON" // info tells you where: "render", "setup()", "watcher" return false // stops bubbling up }) </script> <template> <div v-if="hasError" class="error-state"> {{ errorMsg }} <button @click="hasError = false">Retry</button> </div> <slot v-else /> <!-- child crash lands here, gets caught --> </template>

Wrap any risky child component with this. When the child throws, the slot switches to the error state. The rest of the page stays intact.

How errors propagate

Vue's runtime treats component errors like DOM events: they bubble up the tree. A sync error in a grandchild's setup() or render travels up to the nearest ancestor that has onErrorCaptured. That hook receives three arguments: the error object, the component instance, and an info string like "render" or "setup()".

If the hook returns false, propagation stops there. Otherwise the error keeps traveling upward, eventually reaching app.config.errorHandler. That global handler fires last, for everything local boundaries did not stop.

The info parameter is worth logging. "setup()" means the component failed during initialization. "render" means it failed while evaluating the template. Skipping it makes debugging slower.

When to use each mechanism

  • Isolate one risky widget from the rest of the UI: onErrorCaptured in a wrapper component around that widget
  • Log all errors to Sentry or Datadog: app.config.errorHandler in main.ts
  • Async fetch failing inside a component: try/catch around the await call
  • Unhandled Promise rejections outside Vue: window.addEventListener('unhandledrejection', ...)
  • Dev-only warnings: app.config.warnHandler, stripped from production builds

onErrorCaptured is not the right tool for async code. That is the mistake most developers hit first.

How the renderer handles this internally

When a sync error fires inside a child's setup or render cycle, Vue's patch phase catches it before it can corrupt the parent state. The failed component subtree is skipped, and control passes to the error event system, which walks up via onErrorCaptured hooks. The parent DOM stays mounted. In Vue 3.4+, async setup errors inside <Suspense> are surfaced to the nearest boundary instead of bypassing it.

Common mistakes

1. Assuming onErrorCaptured catches async rejections.

vue
<script setup> async function loadData() { throw new Error('fetch failed') } loadData() // Promise rejects, onErrorCaptured never fires </script>

Fix: wrap async calls in try/catch, then update a reactive error ref manually.

2. Forgetting return false.

vue
onErrorCaptured((err) => { console.log(err) // logs, but error still bubbles to the global handler })

Without return false, the error reaches app.config.errorHandler and the browser console. Add it when you want to contain the error at this level.

3. Placing the boundary in the same component that throws.

onErrorCaptured only catches errors from descendants, not the component's own errors. If your component throws during its own setup(), the hook in that same component never fires. Use a dedicated wrapper like ErrorBoundary.vue.

4. No retry mechanism.

Clearing the error ref is not enough if the child component still holds bad state. On retry, also reset whatever data caused the crash before re-mounting the child. A bare "Try Again" button that only clears the error ref often re-triggers the same crash immediately.

5. Ignoring the info param.

The strings "render", "setup()", "watcher" pinpoint the lifecycle phase. Logging just err.message loses that context. Send info to your error tracking service as extra data.

Real-world usage

  • Nuxt.js: sets app.config.errorHandler in plugins/error-handler.client.ts for SSR mismatch detection. Needs a process.client guard to avoid running server-side.
  • Quasar Framework: wraps layouts in boundary components with auto-retry built in.
  • VueUse: composables like useFetch return a reactive error ref so the caller never deals with uncaught exceptions directly.
  • Sentry integration: pass err to Sentry.captureException(err) inside app.config.errorHandler, and attach info plus the component name as extra context.

Follow-up questions

Q: What exactly does onErrorCaptured catch?
A: Sync errors in a descendant's setup(), render, watchers, and lifecycle hooks. It does not catch errors from emitted events, async functions not awaited in a tracked context, or errors inside setTimeout.

Q: What is the difference between onErrorCaptured and app.config.errorHandler?
A: onErrorCaptured is local to a subtree and fires first. app.config.errorHandler is global and fires last. Use both: boundaries for UI isolation, global handler for logging.

Q: How does onErrorCaptured work with <Suspense>?
A: In Vue 3.4+, errors from async setup functions inside <Suspense> are surfaced to the nearest boundary. On older versions they can bypass boundaries entirely. Wrapping async setup in try/catch is the safer path.

Q: Why might an error boundary fail in Nuxt SSR?
A: The hook runs client-side. During server rendering it may not fire as expected. Use process.client guards in Nuxt plugins and handle server errors in server middleware instead.

Q: (Senior) How would you build an error boundary that resets Pinia state on retry?
A: In the retry handler, call store.$reset() before clearing the error ref. Then use nextTick to force the child to re-mount with clean state. Without nextTick, the child may re-render before the store finishes resetting.

Examples

Basic error boundary

vue
<!-- ErrorBoundary.vue --> <script setup> import { ref, onErrorCaptured } from 'vue' const error = ref(null) onErrorCaptured((err, instance, info) => { error.value = { message: err.message, info } return false }) </script> <template> <div v-if="error" class="fallback"> <p>{{ error.message }} (in {{ error.info }})</p> <button @click="error = null">Retry</button> </div> <slot v-else /> </template>
vue
<!-- App.vue --> <template> <ErrorBoundary> <UserDashboard /> </ErrorBoundary> </template>

When UserDashboard throws during render or setup, the boundary catches it and switches to the fallback. Clicking Retry clears the error ref and re-renders the slot.

Dashboard widget with failing API data

vue
<!-- DashboardWidget.vue --> <script setup> import { ref, onErrorCaptured } from 'vue' import UserChart from './UserChart.vue' const error = ref(null) onErrorCaptured((err) => { error.value = err.message // e.g., "Failed to parse JSON" return false }) function retry() { error.value = null } </script> <template> <div class="dashboard-card"> <h3>User Metrics</h3> <div v-if="error" class="error-fallback"> {{ error }} <button @click="retry">Reload Data</button> </div> <UserChart v-else /> </div> </template>

UserChart parses API data during setup. If the API returns malformed JSON, the parse throws synchronously, this boundary catches it, and shows the reload button. The other dashboard widgets keep rendering normally.

Global handler with Sentry

typescript
// main.ts import { createApp } from 'vue' import * as Sentry from '@sentry/vue' import App from './App.vue' const app = createApp(App) app.config.errorHandler = (err, instance, info) => { Sentry.captureException(err, { extra: { info, component: instance?.$options?.name ?? 'unknown', }, }) console.error('[global error]', err) } app.mount('#app')

This catches everything local boundaries did not stop. The info string and component name help locate the failing part of the app without digging through full stack traces.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?