Picture a dashboard with a sales panel and a list of notices. When the sales panel throws, the notices should keep working, the sales panel should say it failed and offer a retry, and your error log should get the details. That takes one ErrorBoundary component and one function in main.ts, both built below.
The examples use a plain Vue 3.5 project created with npm create vue@latest, where the @/ import alias points at src/.
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, 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:
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 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:
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')
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.
<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
erroris 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()clearserror, 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:
<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 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:
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:
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 say to catch it with onErrorCaptured() in the parent component of the <Suspense>. The boundary above already does that, so wrap Suspense in it:
<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.