---
title: "Error handling in Vue: onErrorCaptured, app.config.errorHandler and boundaries · vuejs.school"
description: "Where Vue catches errors for you, how to report them from one place, and how to build an ErrorBoundary component that shows a fallback and a retry button."
canonical_url: "https://vuejs.school/community/blog/error-handling-in-vue"
meta:
  "og:description": "Where Vue catches errors for you, how to report them from one place, and how to build an ErrorBoundary component that shows a fallback and a retry button."
  "og:image": "https://vuejs.school/_og/d/a_~RXJyb3IgaGFuZGxpbmcgaW4gVnVlOiBvbkVycm9yQ2FwdHVyZWQsIGFwcC5jb25maWcuZXJyb3JIYW5kbGVyIGFuZCBib3VuZGFyaWVzIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~RXJyb3IgaGFuZGxpbmcgaW4gVnVlOiBvbkVycm9yQ2FwdHVyZWQsIGFwcC5jb25maWcuZXJyb3JIYW5kbGVyIGFuZCBib3VuZGFyaWVz%2Cdescription_~V2hlcmUgVnVlIGNhdGNoZXMgZXJyb3JzIGZvciB5b3UsIGhvdyB0byByZXBvcnQgdGhlbSBmcm9tIG9uZSBwbGFjZSwgYW5kIGhvdyB0byBidWlsZCBhbiBFcnJvckJvdW5kYXJ5IGNvbXBvbmVudCB0aGF0IHNob3dzIGEgZmFsbGJhY2sgYW5kIGEgcmV0cnkgYnV0dG9uLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9lcnJvci1oYW5kbGluZy1pbi12dWUi%2Cs_x1ralmSnU84wr62C.png"
  "og:image:alt": "Error handling in Vue: onErrorCaptured, app.config.errorHandler and boundaries · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Error handling in Vue: onErrorCaptured, app.config.errorHandler and boundaries · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~RXJyb3IgaGFuZGxpbmcgaW4gVnVlOiBvbkVycm9yQ2FwdHVyZWQsIGFwcC5jb25maWcuZXJyb3JIYW5kbGVyIGFuZCBib3VuZGFyaWVzIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~RXJyb3IgaGFuZGxpbmcgaW4gVnVlOiBvbkVycm9yQ2FwdHVyZWQsIGFwcC5jb25maWcuZXJyb3JIYW5kbGVyIGFuZCBib3VuZGFyaWVz%2Cdescription_~V2hlcmUgVnVlIGNhdGNoZXMgZXJyb3JzIGZvciB5b3UsIGhvdyB0byByZXBvcnQgdGhlbSBmcm9tIG9uZSBwbGFjZSwgYW5kIGhvdyB0byBidWlsZCBhbiBFcnJvckJvdW5kYXJ5IGNvbXBvbmVudCB0aGF0IHNob3dzIGEgZmFsbGJhY2sgYW5kIGEgcmV0cnkgYnV0dG9uLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9lcnJvci1oYW5kbGluZy1pbi12dWUi%2Cs_x1ralmSnU84wr62C.png"
  "twitter:image:alt": "Error handling in Vue: onErrorCaptured, app.config.errorHandler and boundaries · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## Which errors Vue catches, and which it cannot

Vue wraps the code it calls on your behalf in its own error handling. According to the [application API docs](https://vuejs.org/api/application.html#app-config-errorhandler), that covers component renders, event handlers, lifecycle hooks, the `setup()` function, watchers, custom directive hooks and transition hooks. If a handler or a hook returns a promise, Vue attaches to that promise too, so an `async` click handler that rejects is caught the same way as one that throws.

What Vue does not see is code it did not call. A `setTimeout` callback, a promise you started and did not return, a listener you added with `addEventListener` yourself: errors in those go straight to the browser, where `window.onerror` and the `unhandledrejection` event are the only nets. Neither `app.config.errorHandler` nor `onErrorCaptured()` sees them, so when an error seems to skip your handler, first check whether Vue called the code that threw.

## What Vue does when nothing handles an error

Without any handler, Vue behaves differently in the two build modes:

- **In development** the error is logged with a component trace and then thrown again. That is deliberate: it is loud, and it may crash the page.
- **In production** the error is only logged to the console, so one broken widget does not take the page down.

The cost of that quietness is that an error-monitoring script hooked into `window.onerror` never hears about it. Vue 3.5 added a switch for exactly that case:

*src/main.ts*

```
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// Only matters when no errorHandler is set: unhandled errors are
// thrown in production too, so a monitoring SDK on window.onerror sees them.
app.config.throwUnhandledErrorInProduction = true

app.mount('#app')
```

The flag only acts on errors that nothing handled. Once you set `app.config.errorHandler`, every error your components pass up goes to your handler instead, so the flag has nothing left to do. Pick one set-up: a monitoring script that listens on `window.onerror` plus this flag, or your own handler, as in the next section.

## Report errors from one place with app.config.errorHandler

The handler receives three arguments: the error, the component instance where it happened (or `null`), and a string that says what Vue was doing. In development that string reads like `render function`, `native event handler` or `mounted hook`. In a production build it is a link instead, such as `https://vuejs.org/error-reference/#runtime-1`, and the [error reference](https://vuejs.org/error-reference/#runtime-errors) maps the code at its end (`1`, `5`, `m`) back to the same text.

Set the handler once in `src/main.ts` and pass the error to a small `report()` function:

*src/main.ts*

```
import { createApp } from 'vue'
import App from './App.vue'
import { report } from './report'

const app = createApp(App)

app.config.errorHandler = (err, instance, info) => {
  report(err, { info, component: instance?.$options.name ?? instance?.$options.__name })
}

app.mount('#app')
```

*src/report.ts*

```
export function report(err: unknown, context: Record<string, string | undefined>) {
  const error = err instanceof Error ? err : new Error(String(err))
  const body = JSON.stringify({
    message: error.message,
    stack: error.stack,
    path: location.pathname,
    ...context,
  })
  navigator.sendBeacon('/api/client-errors', body)
}
```

`sendBeacon` queues a small POST to `/api/client-errors`, an endpoint on your own server. The browser sends it even if the page is closing, which is what you want from a reporter. The component name comes from `name` when you set one, and otherwise from `__name`, which the compiler fills in from the file name of a `<script setup>` component.

The handler does not touch the UI. Showing something to the user is a job for the component tree, and that is what the next hook is for.

## Build an ErrorBoundary with a fallback and a retry

`onErrorCaptured()` registers a hook that runs when an error propagates up from a descendant component. Not from the component itself: a boundary's own render error goes to its parent's boundary. The hook can change state, which lets a component swap its content for a fallback.

*src/components/ErrorBoundary.vue*

```
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'

const error = ref<Error | null>(null)

onErrorCaptured((err) => {
  error.value = err instanceof Error ? err : new Error(String(err))
})

function retry() {
  error.value = null
}
</script>

<template>
  <div v-if="error" role="alert">
    <p>This section could not be shown.</p>
    <button type="button" @click="retry">Try again</button>
  </div>
  <div v-else>
    <slot />
  </div>
</template>
```

The `v-if`/`v-else` pair does the work:

- **The broken content stays off the page.** While `error` is set, the slot is not rendered at all. The docs warn that an error state must not render the content that caused the error, or the component ends up in an infinite render loop.
- **Retry starts the content from scratch.** Showing the fallback unmounts everything inside the slot, so when `retry()` clears `error`, Vue mounts it again: `setup()` runs again, `onMounted()` runs again, and a request that failed is made again.

You do not need a `:key` to force that remount, and a key on this wrapper can break the boundary. The compiler already gives the `v-if` branch the key `0`, so a key of yours that starts at `0`, such as a retry counter, makes Vue 3.5.26 reuse the `<div>` and leave the section blank instead of showing the fallback.

Use it around a region, not around everything:

*src/pages/Dashboard.vue*

```
<script setup lang="ts">
import ErrorBoundary from '@/components/ErrorBoundary.vue'
import SalesPanel from '@/components/SalesPanel.vue'
import Notices from '@/components/Notices.vue'
</script>

<template>
  <ErrorBoundary>
    <SalesPanel />
  </ErrorBoundary>
  <ErrorBoundary>
    <Notices />
  </ErrorBoundary>
</template>
```

A thrown error in `SalesPanel` now replaces that panel with the fallback, and `Notices` keeps working.

## When to return false, and when not to

The [`onErrorCaptured()` docs](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured) set out the propagation rules. Hooks are called from the bottom of the tree to the top, like a DOM event bubbling. After the hooks, the error goes to `app.config.errorHandler`. A hook that returns `false` stops all of that: no boundaries above it run, and the app handler is not called either. It means "this error has been handled and should be ignored".

That last part is where boundaries go wrong. Here is a version that looks tidy and loses every error it catches:

*src/components/ErrorBoundary.vue**Breaks***

```
onErrorCaptured((err) => {
  error.value = err instanceof Error ? err : new Error(String(err))
  return false
})
```

The fallback appears, the user clicks retry, and your reporter in `main.ts` is never told. The errors that users actually hit become the ones you never see in your logs. The fix is to not return anything, as in the full component above:

*src/components/ErrorBoundary.vue**Works***

```
onErrorCaptured((err) => {
  error.value = err instanceof Error ? err : new Error(String(err))
  // No return value: the error carries on to app.config.errorHandler,
  // which reports it. The boundary only decides what the user sees.
})
```

Return `false` when the boundary really has finished with the error: a component that throws a typed `NotFoundError` you chose to render as an empty state, for example, or an error you already reported inside the hook.

If the hook itself throws, both its error and the original one are sent to `app.config.errorHandler`, so a bug in a boundary does not hide the error it was handling.

## Catch errors from async setup inside Suspense

A component with a top-level `await` in `<script setup>` has an async `setup()`, and a `<Suspense>` parent waits for it. If the awaited promise rejects, Suspense itself does not handle the error: the [Suspense docs](https://vuejs.org/guide/built-ins/suspense.html#error-handling) say to catch it with `onErrorCaptured()` in the parent component of the `<Suspense>`. The boundary above already does that, so wrap Suspense in it:

*src/pages/Reports.vue*

```
<script setup lang="ts">
import ErrorBoundary from '@/components/ErrorBoundary.vue'
import ReportTable from '@/components/ReportTable.vue'
</script>

<template>
  <ErrorBoundary>
    <Suspense>
      <ReportTable />
      <template #fallback>
        <p>Loading the report…</p>
      </template>
    </Suspense>
  </ErrorBoundary>
</template>
```

If `ReportTable` does `const rows = await fetchRows()` and `fetchRows` throws, the boundary shows its fallback, retry re-creates the Suspense and the table, and the request runs again. Suspense is still marked experimental in the docs, so keep the error handling outside it, as here, rather than depending on anything Suspense might add later.

## What to log and what to show

Log for yourself: the message and stack, the `info` value, the component name, the route path, and a build identifier so you can tell which deploy produced the error. The `report()` function above collects all but the last. For that one, set a `VITE_BUILD_ID` environment variable when you build and pass `build: import.meta.env.VITE_BUILD_ID` in the context object in `main.ts`. Vite exposes variables whose names start with `VITE_` to your code through `import.meta.env`.

Show the user much less. A sentence that says which part failed, a way to try again, and nothing from the error object. Stack traces mean nothing to a user, and a raw message from the server can leak table names or internal URLs. If you want a specific message for a specific failure, throw your own error class with a `userMessage` property and let the boundary read that one field.

**Check it stuck**3 questions from the Error handling and debugging quiz.

[**Practice now**](https://vuejs.school/learn/advanced/error-handling-and-debugging?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 7 min read**Vue 3.6 and Vapor Mode: where things stand**](https://vuejs.school/community/blog/vue-3-6-and-vapor-mode-where-things-stand) [Deep dive · 7 min read**Vapor Mode, explained without the hype**](https://vuejs.school/community/blog/vapor-mode-explained-without-the-hype)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/error-handling-in-vue#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-01-08","description":"Where Vue catches errors for you, how to report them from one place, and how to build an ErrorBoundary component that shows a fallback and a retry button.","name":"Error handling in Vue: onErrorCaptured, app.config.errorHandler and boundaries","url":"https://vuejs.school/community/blog/error-handling-in-vue","breadcrumb":{"@id":"https://vuejs.school/community/blog/error-handling-in-vue#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/error-handling-in-vue"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/error-handling-in-vue#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-01-08","description":"Where Vue catches errors for you, how to report them from one place, and how to build an ErrorBoundary component that shows a fallback and a retry button.","headline":"Error handling in Vue: onErrorCaptured, app.config.errorHandler and boundaries","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~RXJyb3IgaGFuZGxpbmcgaW4gVnVlOiBvbkVycm9yQ2FwdHVyZWQsIGFwcC5jb25maWcuZXJyb3JIYW5kbGVyIGFuZCBib3VuZGFyaWVzIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~RXJyb3IgaGFuZGxpbmcgaW4gVnVlOiBvbkVycm9yQ2FwdHVyZWQsIGFwcC5jb25maWcuZXJyb3JIYW5kbGVyIGFuZCBib3VuZGFyaWVz%2Cdescription_~V2hlcmUgVnVlIGNhdGNoZXMgZXJyb3JzIGZvciB5b3UsIGhvdyB0byByZXBvcnQgdGhlbSBmcm9tIG9uZSBwbGFjZSwgYW5kIGhvdyB0byBidWlsZCBhbiBFcnJvckJvdW5kYXJ5IGNvbXBvbmVudCB0aGF0IHNob3dzIGEgZmFsbGJhY2sgYW5kIGEgcmV0cnkgYnV0dG9uLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9lcnJvci1oYW5kbGluZy1pbi12dWUi%2Cs_x1ralmSnU84wr62C.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/error-handling-in-vue#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/error-handling-in-vue#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/error-handling-in-vue#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Error handling in Vue: onErrorCaptured, app.config.errorHandler and boundaries","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~RXJyb3IgaGFuZGxpbmcgaW4gVnVlOiBvbkVycm9yQ2FwdHVyZWQsIGFwcC5jb25maWcuZXJyb3JIYW5kbGVyIGFuZCBib3VuZGFyaWVzIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~RXJyb3IgaGFuZGxpbmcgaW4gVnVlOiBvbkVycm9yQ2FwdHVyZWQsIGFwcC5jb25maWcuZXJyb3JIYW5kbGVyIGFuZCBib3VuZGFyaWVz%2Cdescription_~V2hlcmUgVnVlIGNhdGNoZXMgZXJyb3JzIGZvciB5b3UsIGhvdyB0byByZXBvcnQgdGhlbSBmcm9tIG9uZSBwbGFjZSwgYW5kIGhvdyB0byBidWlsZCBhbiBFcnJvckJvdW5kYXJ5IGNvbXBvbmVudCB0aGF0IHNob3dzIGEgZmFsbGJhY2sgYW5kIGEgcmV0cnkgYnV0dG9uLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9lcnJvci1oYW5kbGluZy1pbi12dWUi%2Cs_x1ralmSnU84wr62C.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~RXJyb3IgaGFuZGxpbmcgaW4gVnVlOiBvbkVycm9yQ2FwdHVyZWQsIGFwcC5jb25maWcuZXJyb3JIYW5kbGVyIGFuZCBib3VuZGFyaWVzIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~RXJyb3IgaGFuZGxpbmcgaW4gVnVlOiBvbkVycm9yQ2FwdHVyZWQsIGFwcC5jb25maWcuZXJyb3JIYW5kbGVyIGFuZCBib3VuZGFyaWVz%2Cdescription_~V2hlcmUgVnVlIGNhdGNoZXMgZXJyb3JzIGZvciB5b3UsIGhvdyB0byByZXBvcnQgdGhlbSBmcm9tIG9uZSBwbGFjZSwgYW5kIGhvdyB0byBidWlsZCBhbiBFcnJvckJvdW5kYXJ5IGNvbXBvbmVudCB0aGF0IHNob3dzIGEgZmFsbGJhY2sgYW5kIGEgcmV0cnkgYnV0dG9uLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9lcnJvci1oYW5kbGluZy1pbi12dWUi%2Cs_x1ralmSnU84wr62C.png"}]}
```