This page sits under the Framework-Specific Crash Recovery & Error Handlers section, which covers runtime interception patterns across the major frontend frameworks.

The failure mode this solves #

A component inside a Vue 3 reactive tree throws during a watchEffect callback. Vue catches the exception internally, logs it to the console, and keeps rendering — but the component’s reactive proxy is now in an indeterminate state. Without a registered app.config.errorHandler, nothing routes that exception to your observability pipeline or triggers a fallback UI. The user sees a frozen widget; your on-call engineer sees silence in Datadog.

Svelte’s compiled output sidesteps Vue’s proxy model, but the absence of any built-in boundary API means a synchronous throw inside an {#each} block can tear down the entire component tree with no recovery path.

Both frameworks need an explicit capture layer that intercepts errors before they vanish into browser console noise.


Prerequisites #


Architecture overview #

The diagram below shows how Vue and Svelte each funnel errors from component space into a shared normalisation layer before reaching telemetry or fallback UI.

Error capture flow for Vue 3 and Svelte Two parallel lanes showing Vue using app.config.errorHandler and onErrorCaptured, and Svelte using window.addEventListener, both feeding into a shared normaliser, then into a telemetry queue and fallback UI renderer. Vue 3 component tree throws in lifecycle / watcher app.config.errorHandler + onErrorCaptured (leaf) Svelte component tree throws / unhandledrejection window error listeners + writable error store Shared normaliser ErrorPayload → telemetry queue Telemetry / Sentry Fallback UI renderer

Core implementation: Vue 3 #

Vue 3 exposes app.config.errorHandler as the primary global catch-all. The handler receives the raw error, the component instance, and a lifecycle-hook context string. Unlike the declarative class-based approach in React Error Boundary Implementation, Vue’s handler operates imperatively at the app root. Pair it with onErrorCaptured in intermediate components for granular fallback UI without propagating errors to the root.

The implementation below wires a type-safe Pinia store to the global handler and exposes a reusable <ErrorFallback> component that consumes store state via slots:

// stores/errorStore.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { App } from 'vue';

export interface ErrorPayload {
  error: Error;
  instance: unknown;     // component instance — avoid serialising directly
  info: string;          // Vue lifecycle hook name, e.g. "setup function"
  timestamp: number;
  reported: boolean;     // deduplication flag for overlapping listeners
}

export const useErrorStore = defineStore('error', () => {
  const activeError = ref<ErrorPayload | null>(null);

  function capture(err: unknown, instance: unknown, info: string) {
    const normalised = err instanceof Error ? err : new Error(String(err));
    // Guard: mark before dispatching so window.onerror doesn't double-report
    (normalised as Error & { _reported?: boolean })._reported = true;
    activeError.value = {
      error: normalised,
      instance,
      info,
      timestamp: Date.now(),
      reported: false,
    };
  }

  function clear() {
    activeError.value = null;
  }

  return { activeError, capture, clear };
});

// main.ts — register before app.mount()
export function registerGlobalErrorHandler(app: App) {
  const store = useErrorStore();
  app.config.errorHandler = (err, instance, info) => {
    store.capture(err, instance, info);
    // Fire telemetry synchronously so it lands even if the tab closes
    window.dispatchEvent(
      new CustomEvent('app:error', {
        detail: { message: (err as Error)?.message, info },
      })
    );
  };
}



<script setup lang="ts">
import { useErrorStore } from '@/stores/errorStore';
const store = useErrorStore();
</script>

Why this works #

app.config.errorHandler runs in Vue’s internal catch block — it is synchronous with respect to the component’s reactive flush, so the Pinia write lands before the next watchEffect tick. The slot-based fallback pattern mirrors fallback UI rendering patterns without coupling the error capture logic to any specific UI template.

Edge cases and gotchas #

Scenario Symptom Mitigation
Async setup() rejection errorHandler never fires; promise silently fails Add window.addEventListener('unhandledrejection', ...) alongside errorHandler
SSR hydration mismatch False-positive error during app.mount() Check info === 'hydration' and log separately; do not render fallback UI for hydration-only mismatches
onErrorCaptured returns false Error is silenced at the component level Use this only for leaf components with known recoverable failures; never at the root
Overlapping window.onerror listener Same error reported twice to telemetry Check the _reported brand on the Error object before dispatching from window.onerror

Core implementation: Svelte #

Svelte compiles components to vanilla JavaScript — there are no reactive proxies and no lifecycle interceptors at the framework level. The nearest equivalent to a global boundary is a combination of window.addEventListener('error', ...) for synchronous throws and window.addEventListener('unhandledrejection', ...) for async failures, both writing to a typed writable store. When paired with SvelteKit, this aligns with the routing-level isolation patterns in Next.js and Nuxt Routing Error Pages.

// lib/stores/errorStore.ts
import { writable, get } from 'svelte/store';

export interface AppError {
  message: string;
  stack?: string;
  timestamp: number;
  recovered: boolean;
  source: 'sync' | 'async';
}

export const appError = writable<AppError | null>(null);

export function setError(error: unknown, source: AppError['source'] = 'sync') {
  const normalised =
    error instanceof Error ? error : new Error(String(error));
  // Deduplicate: if the same message is already active, do not overwrite
  const current = get(appError);
  if (current && current.message === normalised.message) return;
  appError.set({
    message: normalised.message,
    stack: normalised.stack,
    timestamp: Date.now(),
    recovered: false,
    source,
  });
}

export function clearError() {
  appError.set(null);
}
// main.ts — run before SvelteKit's client-side router boots
import { setError } from '$lib/stores/errorStore';

window.addEventListener('error', (event) => {
  // Prevent default browser console output (optional — remove during dev)
  event.preventDefault();
  setError(event.error ?? new Error(event.message), 'sync');
});

window.addEventListener('unhandledrejection', (event) => {
  event.preventDefault();
  setError(event.reason ?? new Error('Unhandled Promise Rejection'), 'async');
});

<script lang="ts">
  import { appError, clearError } from '$lib/stores/errorStore';
</script>

{#if $appError}
  
{:else}
  
{/if}

Why this works #

Svelte’s reactive $appError subscription re-renders <ErrorBoundary> on every store write without a Virtual DOM diff. Because the store is module-singleton, any component anywhere in the tree can write to it via setError(), and the single mounted <ErrorBoundary> will respond. This is the Svelte idiom for what component isolation techniques implements declaratively in React.

Edge cases and gotchas #

Scenario Symptom Mitigation
Transition engine throws Error fires inside Svelte’s internal animation scheduler Wrap custom transition functions in try/catch; call setError() from the catch block
Web Worker message failures Errors don’t reach the main-thread store Implement worker.onerror that postMessage({ type: 'error', message }) back; main thread calls setError() on receipt
Infinite fallback re-render <ErrorBoundary> init code throws, recursively triggering the store Add a retryCount integer to the store; stop mounting <ErrorBoundary> after 3 consecutive writes without a clear
Store subscription leak subscribe() called manually inside a component without cleanup Always use Svelte’s $ auto-subscribe syntax or return the unsubscriber from onDestroy

Advanced variant: shared telemetry queue for both frameworks #

Promise rejections bypass synchronous error boundaries unless explicitly routed. The batching utility below is framework-agnostic: both registerGlobalErrorHandler (Vue) and setError (Svelte) can import and call reportError. For deeper async stack tracing in Vue, see handling async errors in Vue 3 with onErrorCaptured.

// lib/telemetry.ts
interface TelemetryEvent {
  type: 'error' | 'warning';
  payload: Record<string, unknown>;
  timestamp: number;
}

const queue: TelemetryEvent[] = [];
const BATCH_SIZE = 5;
const FLUSH_INTERVAL_MS = 3_000;
let flushTimer: ReturnType<typeof setInterval> | null = null;

function sanitise(payload: Record<string, unknown>): Record<string, unknown> {
  // Never forward raw credentials or tokens to telemetry
  const { password, token, authorization, cookie, ...safe } = payload;
  return safe;
}

async function flushQueue(): Promise<void> {
  if (queue.length === 0) return;
  if (!navigator.onLine) {
    // Defer until connectivity returns; browser will retry via the sync event
    return;
  }
  const batch = queue.splice(0, BATCH_SIZE);
  try {
    await fetch('/api/telemetry', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(batch),
      // keepalive ensures delivery even if the tab closes mid-flight
      keepalive: true,
    });
  } catch {
    // Re-queue with a simple backoff; production code should use exponential delay
    queue.unshift(...batch);
  }
}

export function reportError(
  error: Error,
  context: Record<string, unknown> = {}
): void {
  queue.push({
    type: 'error',
    payload: sanitise({
      message: error.message,
      // Truncate stack to 2 KB to avoid oversized payloads
      stack: error.stack?.slice(0, 2048),
      ...context,
    }),
    timestamp: Date.now(),
  });
  if (queue.length >= BATCH_SIZE) {
    void flushQueue();
  }
}

export function startTelemetry(): void {
  if (flushTimer !== null) return;
  flushTimer = setInterval(() => void flushQueue(), FLUSH_INTERVAL_MS);
  // Flush remaining items on tab visibility change (pagehide covers mobile)
  window.addEventListener('pagehide', () => void flushQueue());
  // Resume delivery when connectivity is restored
  window.addEventListener('online', () => void flushQueue());
}

Call startTelemetry() once at application boot, before mounting the root component. The queue drains at most every 3 seconds or when it hits 5 events, whichever comes first.


Testing and CI/CD validation #

QA pipelines must assert that fallback UI renders and telemetry fires without blocking the main thread. Use Vitest for store-level unit tests and Playwright for end-to-end network-abort scenarios. For state reset and cleanup protocols between test runs, always reset store state in afterEach.

// tests/vitest/errorStore.spec.ts  (Svelte store)
import { get } from 'svelte/store';
import { setError, clearError, appError } from '$lib/stores/errorStore';

describe('Svelte error store fault injection', () => {
  afterEach(() => clearError());

  it('writes a normalised payload for a thrown Error', () => {
    const boom = new Error('Simulated DOM mutation failure');
    setError(boom, 'sync');
    const state = get(appError);
    expect(state).not.toBeNull();
    expect(state!.message).toBe('Simulated DOM mutation failure');
    expect(state!.source).toBe('sync');
    expect(state!.recovered).toBe(false);
  });

  it('deduplicates identical consecutive errors', () => {
    setError(new Error('flash'), 'async');
    const first = get(appError)!.timestamp;
    setError(new Error('flash'), 'async'); // same message — should be ignored
    expect(get(appError)!.timestamp).toBe(first);
  });

  it('clears state on recovery', () => {
    setError(new Error('gone'));
    clearError();
    expect(get(appError)).toBeNull();
  });
});
// tests/playwright/errorFallback.spec.ts
import { test, expect } from '@playwright/test';

test('shows fallback and recovers on API abort', async ({ page }) => {
  // Simulate complete network failure for the dashboard data endpoint
  await page.route('**/api/dashboard', (route) => route.abort('failed'));
  await page.goto('/dashboard');

  // The error boundary should surface an accessible alert region
  const alert = page.locator('[role="alert"]');
  await expect(alert).toBeVisible();
  await expect(alert.getByRole('button', { name: /recover/i })).toBeVisible();

  // Clicking "Recover" should clear the fallback
  await alert.getByRole('button', { name: /recover/i }).click();
  await expect(alert).not.toBeVisible();
});

Deep-dives under this topic #

For async-specific failure modes, handling async errors in Vue 3 with onErrorCaptured covers Promise-based watchers, suspense boundary interactions, and stack-trace normalisation for async rejections that originate outside the component lifecycle.


Frequently Asked Questions #

Does app.config.errorHandler catch errors in async setup()? No. Unhandled promises inside <script setup> bypass the synchronous handler entirely. Register a window.addEventListener('unhandledrejection', ...) listener in addition to app.config.errorHandler, and route both to the same Pinia store to avoid split error states.

Can onErrorCaptured suppress an error from reaching the global handler? Yes — returning false from onErrorCaptured stops propagation to parent components and to app.config.errorHandler. Reserve this for leaf components with fully understood, recoverable failures. Silencing errors globally leads to invisible production issues.

How do I prevent duplicate telemetry when both app.config.errorHandler and window.onerror are active? Brand each Error object with a non-enumerable Symbol or a _reported boolean before the first report. Both listeners check for the brand before dispatching; the second listener short-circuits if it is already set.

What is the Svelte equivalent of a React Error Boundary? Svelte has no declarative boundary primitive. The closest equivalent is a writable error store consumed by a wrapper <ErrorBoundary> component that conditionally renders slot content or a fallback, gated on whether the store holds an active error. This is the pattern shown in the core implementation section above.

How do I handle errors thrown inside Svelte transitions? Svelte transitions run in the same microtask queue as component updates. Wrap your custom transition functions in try/catch and call setError() from the catch block. For built-in transitions (fade, fly), disable them during known high-churn states — for example, while an error recovery animation is in progress — to avoid cascading failures.