Handling Async Errors in Vue 3 with onErrorCaptured
Modern frontend architectures demand resilient error handling that gracefully degrades user experience without triggering full-page crashes. In Vue 3, onErrorCaptured serves as a critical component-level error boundary, but its synchronous execution model introduces specific limitations when managing asynchronous operations. This guide provides production-ready patterns for intercepting, debugging, and recovering from async failures while preserving session integrity and maintaining audit compliance.
Understanding onErrorCaptured and Async Propagation Limits
onErrorCaptured is a lifecycle hook invoked when an error is thrown by a descendant component during rendering, setup execution, or synchronous event handling. It receives three arguments: the error object, the component instance that triggered it, and an information string describing the context. Crucially, this hook operates synchronously within Vue’s reactivity and rendering pipeline. Unhandled promise rejections bypass it entirely because the JavaScript event loop resolves them asynchronously, outside Vue’s synchronous error tracking boundaries.
To establish robust architectural boundaries for Framework-Specific Crash Recovery & Error Handlers, engineers must explicitly bridge the gap between synchronous error boundaries and asynchronous data flows.
// Basic onErrorCaptured signature implementation
import { onErrorCaptured } from 'vue';
export function useComponentErrorBoundary() {
onErrorCaptured((err, instance, info) => {
console.error(
`Captured in ${instance.type.name || 'Unknown'}: ${err.message} [${info}]`
);
// Return false to stop propagation to parent boundaries
return false;
});
}
// Error object type narrowing utility
export function isNetworkError(err: unknown): err is { status: number; message: string } {
return typeof err === 'object' && err !== null && 'status' in err;
}
export function isVueRenderError(err: unknown): err is Error {
return err instanceof Error && err.stack?.includes('renderComponent');
}
// Component tree traversal helper for context enrichment
export function getComponentHierarchy(
instance: ComponentPublicInstance | null
): string[] {
const path: string[] = [];
let current = instance;
while (current) {
path.unshift(current.$options.name || current.$options.__name || 'Anonymous');
current = current.$parent;
}
return path;
}
Edge Cases to Monitor:
- Errors thrown inside
nextTickcallbacks execute after the current render cycle, bypassing the activeonErrorCapturedscope. - Async composables returning rejected promises without explicit
try/catchor.catch()chains will trigger globalunhandledrejectionevents instead of component boundaries. - Third-party SDK async initialization failures often occur outside Vue’s component tree, requiring wrapper adapters to funnel errors into
onErrorCaptured.
Common Pitfalls:
- Assuming
onErrorCapturedcatches all async failures leads to silent crashes in production. - Returning
truefrom the hook signals Vue to continue propagating the error upward, which can trigger infinite error loops if parent boundaries also returntrue. - Mutating reactive state inside the error handler without deferring via
nextTickcan cause re-render conflicts and secondary exceptions.
Crash Reproduction Workflows for QA & Engineering
Deterministic crash reproduction requires isolating async failure vectors. QA and engineering teams should implement controlled network degradation, promise rejection factories, and race-condition simulators to validate boundary consistency across deployment environments.
// Vitest/Jest async rejection mock factory
import { vi } from 'vitest';
export function mockAsyncRejection<T>(delayMs: number, errorPayload: Error) {
return vi
.fn()
.mockImplementation(
() => new Promise<T>((_, reject) => setTimeout(() => reject(errorPayload), delayMs))
);
}
// Network interceptor for timeout simulation (using MSW or fetch wrapper)
export function simulateNetworkTimeout(url: string, timeoutMs: number) {
const originalFetch = window.fetch;
window.fetch = async (input, init) => {
if (typeof input === 'string' && input.includes(url)) {
await new Promise((res) => setTimeout(res, timeoutMs));
throw new DOMException('Network timeout', 'TimeoutError');
}
return originalFetch(input, init);
};
}
- {{ err.message }}
Edge Cases to Monitor:
- Concurrent API calls failing in unpredictable order can cause partial state mutations before the boundary intercepts the first rejection.
- Web Worker async message failures require explicit
postMessageerror routing back to the main thread. - Service Worker cache poisoning triggering async parse errors manifests as silent data corruption rather than explicit exceptions.
Common Pitfalls:
- Non-deterministic test suites masking intermittent crashes due to uncontrolled timing variations.
- Over-mocking dependencies and missing real-world failure modes like TLS handshake drops or DNS resolution delays.
- Ignoring
unhandledrejectionglobal events during test execution, leaving async boundary gaps unverified.
Debugging Workflows & Memory Analysis
When async errors trigger component teardown, improper cleanup frequently results in detached DOM nodes, retained closures, and memory leaks. Engineers should correlate heap snapshots across pre- and post-error states to identify retained references. Cross-referencing these strategies with Vue & Svelte Global Error Handlers standardizes memory profiling across modern frameworks.
// Async operation cleanup composable
import { ref, onUnmounted } from 'vue';
export function useAsyncCleanup() {
const controllers = ref<AbortController[]>([]);
const createController = () => {
const ctrl = new AbortController();
controllers.value.push(ctrl);
return ctrl;
};
onUnmounted(() => {
controllers.value.forEach((c) => c.abort());
controllers.value = [];
});
return { createController };
}
// Memory leak detection script using performance.memory
export function trackHeapUsage(label: string) {
if ('memory' in performance) {
const mem = (performance as any).memory;
console.log(
`[${label}] Used: ${mem.usedJSHeapSize} | Total: ${mem.totalJSHeapSize} | Limit: ${mem.jsHeapSizeLimit}`
);
}
}
// Vue DevTools error timeline integration
import { devtools } from '@vue/devtools-api';
export function logErrorToTimeline(err: Error, context: string) {
if (devtools) {
devtools.emit('vue:error', {
error: err,
timestamp: Date.now(),
context,
stack: err.stack?.split('\n').slice(0, 5).join('\n'),
});
}
}
Edge Cases to Monitor:
- Event listeners attached in async callbacks not removed on error leave dangling references to destroyed component instances.
- Large error payloads retained in memory due to circular references in SDK response objects prevent garbage collection.
AbortControllersignals ignored during component teardown allow background fetches to complete and mutate unmounted state.
Common Pitfalls:
- Serializing massive stack traces for logging causes main thread jank and delays UI recovery.
- Blocking garbage collection by retaining error objects in global diagnostic arrays.
- Misinterpreting Vue reactivity proxy objects in heap snapshots as actual memory leaks.
Session State Preservation & Rollback Procedures
Async failures during data mutations can leave the UI in an inconsistent state. Implementing transactional state management with optimistic UI rollback ensures user progress is preserved without requiring full page reloads.
// State snapshot utility with deep clone fallback
export function createSnapshot<T>(state: T): T {
try {
return structuredClone(state);
} catch {
return JSON.parse(JSON.stringify(state));
}
}
// Rollback composable with undo/redo queue
import { ref } from 'vue';
export function useRollbackState<T>(initialState: T) {
const history = ref<T[]>([initialState]);
const current = ref<T>(initialState);
const pointer = ref(0);
const commit = (newState: T) => {
history.value = history.value.slice(0, pointer.value + 1);
history.value.push(newState);
pointer.value++;
current.value = newState;
};
const rollback = () => {
if (pointer.value > 0) {
pointer.value--;
current.value = history.value[pointer.value];
}
};
return { current, commit, rollback };
}
// Optimistic update wrapper with automatic revert
export async function optimisticUpdate<T>(
asyncOperation: () => Promise<T>,
applyOptimistic: (val: T) => void,
revert: () => void
) {
const result = await asyncOperation();
applyOptimistic(result);
return result;
}
// Usage wrapper
export function withOptimisticFallback<T>(
operation: () => Promise<T>,
onApply: (val: T) => void,
onRevert: () => void
) {
return operation().catch(() => {
onRevert();
throw new Error('Optimistic update reverted due to async failure');
});
}
Edge Cases to Monitor:
- Partial mutations before async rejection triggers leave hybrid states that fail validation on retry.
- Concurrent state writes during rollback execution cause race conditions and data corruption.
- Cross-tab session desync after error recovery requires
BroadcastChannelsynchronization orlocalStorageversioning.
Common Pitfalls:
- Rolling back to stale state causing UI inconsistencies when the user has already navigated or modified dependent fields.
- Infinite retry loops exhausting browser memory when network conditions remain degraded.
- Failing to clear pending async tasks before state restoration triggers duplicate mutations.
Telemetry Correlation & Audit Trails
Captured errors must be mapped to actionable telemetry data for post-mortem analysis. Structured audit trails linking session IDs, user action sequences, and network traces enable precise root-cause identification and compliance reporting.
// Structured telemetry payload builder
export interface TelemetryPayload {
errorId: string;
sessionId: string;
timestamp: number;
componentPath: string[];
networkTrace: { url: string; status?: number; duration: number }[];
userActions: string[];
environment: string;
}
export function buildTelemetryPayload(
error: Error,
context: Record<string, any>
): TelemetryPayload {
return {
errorId: crypto.randomUUID(),
sessionId: sessionStorage.getItem('session_id') || 'unknown',
timestamp: Date.now(),
componentPath: context.componentPath || [],
networkTrace: context.networkTrace || [],
userActions: context.actionQueue?.slice(-5) || [],
environment: import.meta.env.MODE,
};
}
// Error fingerprinting algorithm (stack hash + route + component)
export function generateErrorFingerprint(
err: Error,
route: string,
component: string
): string {
const stackHash = btoa(err.stack || '').slice(0, 16);
const normalizedRoute = route.replace(/\/\d+/g, '/:id');
return `${normalizedRoute}::${component}::${stackHash}`;
}
// Audit log serializer with PII redaction
const PII_PATTERNS = [
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, // Email
/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g, // Credit Card
/\b\d{3}[-\s]?\d{3}[-\s]?\d{4}\b/g, // Phone
];
export function serializeAuditLog(payload: Record<string, any>): string {
const sanitized = JSON.stringify(payload, (key, value) => {
if (typeof value === 'string') {
return value
.replace(PII_PATTERNS[0], '[EMAIL_REDACTED]')
.replace(PII_PATTERNS[1], '[CC_REDACTED]')
.replace(PII_PATTERNS[2], '[PHONE_REDACTED]');
}
return value;
});
return sanitized;
}
Edge Cases to Monitor:
- Network telemetry flush failing during offline mode requires local queue persistence and retry on connectivity restoration.
- High-cardinality tags (e.g., dynamic route params, user IDs) causing telemetry quota exhaustion must be normalized before ingestion.
- Minified production stack traces requiring source map resolution should be processed server-side to avoid client overhead.
Common Pitfalls:
- Leaking sensitive user data in error context objects violates GDPR/CCPA compliance.
- Over-sampling errors and missing critical crash patterns due to aggressive deduplication thresholds.
- Blocking UI rendering during synchronous telemetry dispatch; always use
navigator.sendBeaconor deferredfetchwithkeepalive.
Frequently Asked Questions
Does onErrorCaptured intercept unhandled promise rejections in Vue 3?
No. onErrorCaptured only catches synchronous errors thrown during component rendering, lifecycle hooks, or event handlers. Async rejections require explicit try/catch blocks, .catch() chains, or a global window.addEventListener('unhandledrejection') handler.
How do I prevent infinite error loops when onErrorCaptured triggers?
Return false from the hook to stop propagation. Implement a circuit breaker pattern that tracks error frequency per component instance and disables the error handler after a threshold to prevent recursive crashes.
Can onErrorCaptured replace global error boundaries for crash recovery?
It is component-scoped and cannot catch errors outside the Vue component tree. For full application crash recovery, combine it with app-level error handlers, session state managers, and routing fallbacks.