Advanced Error Handling and Component Isolation

In the world of modern web applications, errors are not exceptions to be feared but inevitable realities to be managed gracefully. A user-facing crash, an unhandled promise rejection, or a component that fails to render can transform a polished application into a frustrating experience. Svelte 5 introduces the <svelte:boundary> special element as its answer to this challenge—a declarative, composable mechanism for isolating failures and maintaining application stability even when individual components encounter problems.

This guide explores the <svelte:boundary> element in comprehensive depth, examining not just its syntax and properties but the architectural thinking behind effective error handling in component-based applications. Whether you’re building complex data-driven dashboards, real-time collaborative tools, or consumer-facing products where reliability is paramount, understanding error boundaries will fundamentally change how you approach resilience in your Svelte applications.

Understanding the Philosophy Behind Error Boundaries

Before diving into implementation details, it’s worth considering why error boundaries exist as a pattern and what problems they solve. In traditional imperative programming, error handling typically involves try-catch blocks wrapped around specific operations. While this approach works for isolated operations, component-based architectures present unique challenges.

Consider a typical Svelte application: you have a parent component rendering multiple children, each potentially fetching data, performing calculations, or rendering complex visualizations. When one of these children throws an error during rendering or while running effects, what should happen? Without error boundaries, the entire component tree above the failing component would crash, often taking down your whole application or leaving users staring at a blank screen.

Error boundaries provide what we might call “blast radius containment.” When a component fails within a boundary, the boundary catches the error, removes the failing content, and optionally renders fallback UI—all while the rest of your application continues functioning normally. This isolation is particularly valuable in applications where different sections operate independently: a failing comments widget shouldn’t prevent users from reading an article, and a broken analytics chart shouldn’t lock users out of their entire dashboard.

The <svelte:boundary> element, introduced in Svelte 5.3.0, brings this pattern to Svelte with an elegance that aligns with Svelte’s philosophy of minimal boilerplate and maximum expressiveness.

The Anatomy of svelte:boundary

At its core, <svelte:boundary> is a special element that wraps content you want to protect. The basic structure looks deceptively simple:

<svelte:boundary><ProtectedContent /></svelte:boundary>

However, this minimal form does nothing useful—it’s a boundary without any handling. For the boundary to actually manage errors or loading states, you need to provide one or more of its three main properties: pending, failed, and onerror. Each serves a distinct purpose in the error and loading state management lifecycle.

When an error occurs within a <svelte:boundary>, the boundary’s behavior follows a specific sequence. First, if an onerror handler is provided, it receives the error and a reset function. Then, if a failed snippet is provided, the boundary removes all existing content and renders the snippet instead. If no failed snippet exists but onerror is present, the content is simply removed.

This destruction of the boundary’s existing content is important to understand — errors don’t freeze the UI in a broken state; they trigger a complete replacement with whatever fallback you’ve defined.

Handling Asynchronous Operations with the pending Snippet

Svelte 5 introduced inline await expressions that allow you to write asynchronous code directly in your templates. While powerful, this creates a question: what should users see while waiting for async operations to complete? The pending snippet provides the answer.

When a <svelte:boundary> contains await expressions, the pending snippet is shown initially while those promises are resolving:

<script>
	async function fetchUserProfile(userId) {
		const response = await fetch(`/api/users/${userId}`)
		if (!response.ok) throw new Error('Failed to load profile')
		return response.json()
	}

	let { userId } = $props()
</script>

<svelte:boundary>
	<article class="user-profile">
		<h2>{await fetchUserProfile(userId).then((user) => user.name)}</h2>
		<p class="bio">{await fetchUserProfile(userId).then((user) => user.bio)}</p>
		<span class="joined">
			Member since {await fetchUserProfile(userId).then((user) =>
				new Date(user.createdAt).toLocaleDateString()
			)}
		</span>
	</article>

	{#snippet pending()}
		<article class="user-profile skeleton">
			<h2 class="skeleton-text">Loading...</h2>
			<p class="bio skeleton-text">&nbsp;</p>
			<span class="joined skeleton-text">&nbsp;</span>
		</article>
	{/snippet}
</svelte:boundary>

The pending snippet functions as an initial loading state — it appears when the boundary first mounts and disappears once all await expressions within the boundary have resolved. This makes it ideal for skeleton screens, loading spinners, or placeholder content that matches the eventual layout.

An important distinction exists between initial loading and subsequent updates. The pending snippet only appears during the boundary’s initial resolution phase. If your async data later updates (perhaps due to a reactive dependency changing), the pending snippet won’t reappear. For handling loading states during subsequent updates, Svelte provides the $effect.pending() rune, which returns the count of pending promises within the current boundary:

<script>
	async function fetchData(query) {
		const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
		return response.json()
	}

	let query = $state('')
	let debouncedQuery = $state('')

	$effect(() => {
		const timeout = setTimeout(() => {
			debouncedQuery = query
		}, 300)
		return () => clearTimeout(timeout)
	})
</script>

<input bind:value={query} placeholder="Search..." />

<svelte:boundary>
	<ul class="search-results">
		{#each await fetchData(debouncedQuery) as result}
			<li>{result.title}</li>
		{/each}
	</ul>

	{#snippet pending()}
		<p>Loading initial results...</p>
	{/snippet}
</svelte:boundary>

{#if $effect.pending()}
	<div class="loading-indicator">
		Updating results... ({$effect.pending()} pending)
	</div>
{/if}

This separation between initial loading (pending snippet) and update loading ($effect.pending()) gives you fine-grained control over your loading UX without conflating different types of loading states.

Graceful Error Recovery with the failed Snippet

The failed snippet is where <svelte:boundary> truly demonstrates its value. When an error occurs during rendering or while running effects within the boundary, the failed snippet replaces the boundary’s content entirely, providing a user-friendly fallback and the tools needed for recovery.

The snippet receives two arguments: the error object itself and a reset function that recreates the boundary’s contents, giving users the opportunity to retry:

<script>
	import DataVisualization from './DataVisualization.svelte'

	let { dataSource } = $props()
</script>

<svelte:boundary>
	<DataVisualization source={dataSource} />

	{#snippet failed(error, reset)}
		<div class="error-container" role="alert">
			<svg class="error-icon" viewBox="0 0 24 24" aria-hidden="true">
				<path
					d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
				/>
			</svg>

			<h3>Something went wrong</h3>
			<p class="error-message">{error.message}</p>

			<div class="error-actions">
				<button onclick={reset} class="retry-button"> Try again </button>
				<button
					onclick={() => navigator.clipboard.writeText(error.stack ?? error.message)}
					class="copy-button"
				>
					Copy error details
				</button>
			</div>

			{#if import.meta.env.DEV}
				<details class="error-details">
					<summary>Technical details</summary>
					<pre>{error.stack}</pre>
				</details>
			{/if}
		</div>
	{/snippet}
</svelte:boundary>

The reset function deserves special attention. When called, it completely destroys and recreates the boundary’s contents, re-running any initialization logic, effects, and data fetching. This is powerful but comes with considerations:

  • Any local state within the boundary’s components is lost
  • Network requests will be re-executed
  • Effects will re-run from scratch

This behavior is usually desirable — if an error occurred, you want a fresh start. However, for cases where you want to preserve some state across resets, you’ll need to lift that state above the boundary or use context.

The failed snippet can also be passed explicitly as a prop rather than declared inline, which enables powerful patterns for reusable error handling:

<script>
	import { errorFallback } from './error-handlers.js'
</script>

<svelte:boundary failed={errorFallback}><RiskyComponent /></svelte:boundary>

Programmatic Error Handling with onerror

While the failed snippet handles the visual representation of errors, the onerror handler provides a hook for programmatic responses. This is essential for error tracking, analytics, and complex recovery scenarios.

The onerror function receives the same arguments as the failed snippet—the error and a reset function—allowing you to implement sophisticated error handling logic:

<script>
	import { trackError } from './analytics.js'
	import { addToast } from './toast-store.svelte.js'

	function handleComponentError(error, reset) {
		// Track the error for monitoring
		trackError({
			component: 'UserDashboard',
			message: error.message,
			stack: error.stack,
			timestamp: Date.now(),
			url: window.location.href,
			userAgent: navigator.userAgent
		})

		// Show a toast notification
		addToast({
			type: 'error',
			message: "Something went wrong. We've been notified.",
			action: {
				label: 'Retry',
				callback: reset
			}
		})

		// Log to console in development
		if (import.meta.env.DEV) {
			console.error('Boundary caught error:', error)
		}
	}
</script>

<svelte:boundary onerror={handleComponentError}>
	<UserDashboard />

	{#snippet failed(error, reset)}
		<div class="dashboard-error">
			<p>Unable to load your dashboard.</p>
			<button onclick={reset}>Reload dashboard</button>
		</div>
	{/snippet}
</svelte:boundary>

A particularly powerful pattern involves using onerror to expose error state outside the boundary, enabling external components to respond to errors:

<script>
	let boundaryError = $state(null)
	let boundaryReset = $state(() => {})

	function captureError(error, reset) {
		boundaryError = error
		boundaryReset = reset
	}

	function clearAndReset() {
		boundaryError = null
		boundaryReset()
	}
</script>

<!-- Error notification outside the boundary -->
{#if boundaryError}
	<aside class="global-error-banner" role="alert">
		<p>A component encountered an error: {boundaryError.message}</p>
		<button onclick={clearAndReset}>Dismiss and retry</button>
	</aside>
{/if}

<main>
	<svelte:boundary onerror={captureError}><ApplicationContent /></svelte:boundary>
</main>

This pattern is particularly valuable when your error UI needs to appear in a different location than where the error occurred—perhaps in a global notification area or a modal overlay.

One critical behavior to understand: if an error occurs within the onerror handler itself (or if you explicitly rethrow the error), it will propagate up to the next parent boundary. This enables deliberate error escalation when a boundary determines it cannot handle a particular error:

<script>
	function handleError(error, reset) {
		// Only handle recoverable errors locally
		if (error.name === 'NetworkError' || error.name === 'TimeoutError') {
			// Handle recoverable errors
			console.log('Recoverable error, allowing retry')
			return
		}

		// Escalate critical errors to parent boundary
		throw error
	}
</script>

<svelte:boundary onerror={handleError}>
	<CriticalComponent />

	{#snippet failed(error, reset)}
		<!-- This only shows for recoverable errors -->
		<button onclick={reset}>Connection lost. Click to retry.</button>
	{/snippet}
</svelte:boundary>

Advanced Architectural Patterns

Understanding the mechanics of <svelte:boundary> is just the beginning. The real power emerges when you apply these primitives to solve complex architectural challenges.

Hierarchical Boundary Strategy

Large applications benefit from multiple boundary layers, each responsible for different granularities of failure. Consider a dashboard application with multiple widgets:

<!-- App.svelte - Root boundary catches catastrophic failures -->
<script>
	import Dashboard from './Dashboard.svelte'
	import { reportCriticalError } from './monitoring.js'
</script>

<svelte:boundary onerror={(e) => reportCriticalError(e)}>
	<Dashboard />

	{#snippet failed(error, reset)}
		<div class="app-crash">
			<h1>Application Error</h1>
			<p>We're sorry, but something went seriously wrong.</p>
			<button onclick={() => window.location.reload()}> Reload Application </button>
		</div>
	{/snippet}
</svelte:boundary>
<!-- Dashboard.svelte - Section boundaries isolate widget failures -->
<script>
	import Widget from './Widget.svelte'
	import { widgets } from './widget-config.js'
</script>

<div class="dashboard-grid">
	{#each widgets as widget (widget.id)}
		<svelte:boundary>
			<Widget config={widget} />

			{#snippet failed(error, reset)}
				<div class="widget-error">
					<p>Widget failed to load</p>
					<button onclick={reset}>Retry</button>
				</div>
			{/snippet}
		</svelte:boundary>
	{/each}
</div>
<!-- Widget.svelte - Fine-grained boundaries within widgets -->
<script>
	let { config } = $props()
</script>

<article class="widget">
	<header class="widget-header">
		<h3>{config.title}</h3>
	</header>

	<!-- Data section with its own boundary -->
	<svelte:boundary>
		<section class="widget-data">
			{#await config.fetchData()}
				<p>Loading data...</p>
			{:then data}
				<config.renderer {data} />
			{/await}
		</section>

		{#snippet pending()}
			<div class="widget-skeleton">Loading...</div>
		{/snippet}

		{#snippet failed(error, reset)}
			<div class="data-error">
				<p>Data unavailable</p>
				<button onclick={reset}>Refresh</button>
			</div>
		{/snippet}
	</svelte:boundary>

	<!-- Controls remain functional even if data fails -->
	<footer class="widget-controls">
		<button onclick={config.onSettings}>Settings</button>
	</footer>
</article>

This three-tier approach ensures that a data loading failure in one widget doesn’t affect other widgets, and a widget failure doesn’t crash the entire dashboard, while still having a safety net at the application level for truly unexpected failures.

Creating Reusable Error Boundary Components

For consistency across your application, you can create wrapper components that encapsulate your error handling patterns:

<!-- ErrorBoundary.svelte -->
<script>
	import { getContext } from 'svelte'
	import type { Snippet } from 'svelte'

	interface Props {
		/** Component name for error tracking */
		name?: string
		/** Whether to show retry button in fallback */
		retryable?: boolean
		/** Custom fallback snippet */
		fallback?: Snippet<[Error, () => void]>
		/** Called when error is caught */
		onError?: (error: Error, componentName: string) => void
		/** Children content */
		children: Snippet
	}

	let {
		name = 'Unknown Component',
		retryable = true,
		fallback,
		onError,
		children
	}: Props = $props()

	// Get app-wide error reporter from context if available
	const reportError = getContext('errorReporter')

	function handleError(error: Error, reset: () => void) {
		// Report to monitoring
		if (reportError) {
			reportError({ error, component: name, timestamp: Date.now() })
		}

		// Call custom handler if provided
		onError?.(error, name)

		// Log in development
		if (import.meta.env.DEV) {
			console.error(`[${name}] Error:`, error)
		}
	}
</script>

<svelte:boundary onerror={handleError}>
	{@render children()}

	{#snippet failed(error, reset)}
		{#if fallback}
			{@render fallback(error, reset)}
		{:else}
			<div class="error-boundary-fallback" role="alert">
				<div class="error-icon">⚠️</div>
				<h4>Something went wrong</h4>
				<p>{error.message}</p>
				{#if retryable}
					<button onclick={reset} class="retry-btn"> Try again </button>
				{/if}
			</div>
		{/if}
	{/snippet}
</svelte:boundary>

<style>
	.error-boundary-fallback {
		padding: 1.5rem;
		border: 1px solid var(--color-error-border, #fca5a5);
		border-radius: 0.5rem;
		background: var(--color-error-bg, #fef2f2);
		text-align: center;
	}

	.error-icon {
		font-size: 2rem;
		margin-bottom: 0.5rem;
	}

	.error-boundary-fallback h4 {
		margin: 0 0 0.5rem;
		color: var(--color-error-heading, #991b1b);
	}

	.error-boundary-fallback p {
		margin: 0 0 1rem;
		color: var(--color-error-text, #b91c1c);
		font-size: 0.875rem;
	}

	.retry-btn {
		padding: 0.5rem 1rem;
		border: none;
		border-radius: 0.25rem;
		background: var(--color-error-button, #dc2626);
		color: white;
		cursor: pointer;
		font-weight: 500;
	}

	.retry-btn:hover {
		background: var(--color-error-button-hover, #b91c1c);
	}
</style>

Usage becomes clean and consistent:

<script>
	import ErrorBoundary from './ErrorBoundary.svelte'
	import UserProfile from './UserProfile.svelte'
	import ActivityFeed from './ActivityFeed.svelte'
</script>

<div class="page-layout">
	<ErrorBoundary name="UserProfile" retryable={true}>
		<UserProfile />
	</ErrorBoundary>

	<ErrorBoundary name="ActivityFeed" retryable={true}>
		<ActivityFeed />
	</ErrorBoundary>
</div>

Combining with Context for Application-Wide Error Management

For large applications, you might want centralized error tracking with the flexibility of component-level handling:

<!-- ErrorProvider.svelte -->
<script>
	import { setContext } from 'svelte'

	interface ErrorRecord {
		id: string
		error: Error
		component: string
		timestamp: number
		resolved: boolean
	}

	let errors = $state<ErrorRecord[]>([])

	function reportError(details: { error: Error; component: string; timestamp: number }) {
		const record: ErrorRecord = {
			id: crypto.randomUUID(),
			...details,
			resolved: false
		}
		errors.push(record)

		// Could also send to external service here
		if (import.meta.env.PROD) {
			fetch('/api/errors', {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({
					message: details.error.message,
					stack: details.error.stack,
					component: details.component,
					timestamp: details.timestamp,
					url: window.location.href
				})
			}).catch(() => {
				// Silently fail - don't want error reporting to cause errors
			})
		}
	}

	function resolveError(id: string) {
		const error = errors.find((e) => e.id === id)
		if (error) error.resolved = true
	}

	function clearResolvedErrors() {
		errors = errors.filter((e) => !e.resolved)
	}

	setContext('errorReporter', reportError)
	setContext('errorResolver', resolveError)

	let { children } = $props()
</script>

<!-- Global error indicator -->
{#if errors.some((e) => !e.resolved)}
	<div class="error-indicator" role="status">
		{errors.filter((e) => !e.resolved).length} component(s) encountered errors
		<button onclick={clearResolvedErrors}>Clear resolved</button>
	</div>
{/if}

{@render children()}

Integration with SvelteKit’s Error Handling

When using SvelteKit, <svelte:boundary> complements rather than replaces SvelteKit’s built-in error handling. Understanding how these systems interact is crucial for building robust applications.

SvelteKit’s error system operates at the route level. When an error occurs in a load function or during server-side rendering, SvelteKit renders the nearest +error.svelte page. This happens before any client-side JavaScript runs, so <svelte:boundary> cannot catch these errors—they happen at a different layer of the application.

However, once a page has loaded successfully and is running on the client, <svelte:boundary> becomes your primary tool for handling runtime errors:

<!-- +page.svelte -->
<script>
	import { page } from '$app/state'
	import InteractiveChart from './InteractiveChart.svelte'
	import CommentSection from './CommentSection.svelte'

	let { data } = $props()
</script>

<!-- This content loaded successfully from +page.server.js -->
<article>
	<h1>{data.article.title}</h1>
	<div class="content">
		{@html data.article.content}
	</div>
</article>

<!-- Interactive features wrapped in boundaries -->
<svelte:boundary>
	<InteractiveChart data={data.chartData} />

	{#snippet failed(error, reset)}
		<div class="chart-fallback">
			<p>Chart could not be rendered</p>
			<button onclick={reset}>Retry</button>
		</div>
	{/snippet}
</svelte:boundary>

<svelte:boundary>
	<CommentSection articleId={data.article.id} />

	{#snippet failed(error, reset)}
		<div class="comments-fallback">
			<p>Comments unavailable</p>
			<button onclick={reset}>Load comments</button>
		</div>
	{/snippet}
</svelte:boundary>

This pattern ensures that even if client-side interactive features fail, the core content remains visible. The article text loaded from the server displays normally while the interactive chart and comments—which might fail due to client-side JavaScript errors or network issues—gracefully degrade.

For errors that should navigate users to an error page, you can use SvelteKit’s error helper in combination with boundaries:

<script>
	import { error } from '@sveltejs/kit'
	import { goto } from '$app/navigation'

	function handleCriticalError(err, reset) {
		// For critical errors, navigate to error page
		if (err.name === 'AuthenticationError') {
			goto('/login?error=session_expired')
			return
		}

		if (err.name === 'NotFoundError') {
			error(404, { message: 'Resource not found' })
		}

		// For other errors, let the boundary handle it normally
	}
</script>

<svelte:boundary onerror={handleCriticalError}>
	<ProtectedContent />

	{#snippet failed(error, reset)}
		<!-- Only shown for non-critical errors -->
		<button onclick={reset}>Retry</button>
	{/snippet}
</svelte:boundary>

Common Pitfalls and How to Avoid Them

Experience with error boundaries reveals several patterns that, while seemingly reasonable, lead to problematic behavior.

1. Boundaries That Catch Too Much

Wrapping your entire application in a single boundary might seem like comprehensive protection, but it creates a poor user experience:

<!-- Don't do this -->
<svelte:boundary>
	<Header />
	<Navigation />
	<MainContent />
	<Footer />

	{#snippet failed(error, reset)}
		<p>Something went wrong. <button onclick={reset}>Retry</button></p>
	{/snippet}
</svelte:boundary>

If any component fails, users lose the entire UI, including navigation that might help them recover. Instead, identify logical boundaries in your application and protect them independently.

2. Assuming Effects Are Safe

Boundaries catch errors from effects ($effect), but the timing can be surprising. Effects run after the DOM updates, so an error in an effect might occur after the component has successfully rendered once:

<script>
	let data = $state(null)

	$effect(() => {
		// This effect runs after initial render
		if (data.someProperty) {
			// Throws if data is null
			// ...
		}
	})
</script>

<svelte:boundary>
	<div>This renders, then the effect throws</div>

	{#snippet failed(error, reset)}
		<p>Error: {error.message}</p>
	{/snippet}
</svelte:boundary>

The component renders successfully, then the effect runs and throws, and only then does the boundary show the fallback. Users might see a flash of content before the error state appears. Guard against this by validating state before accessing properties:

$effect(() => {
  if (!data) return; // Guard clause
  if (data.someProperty) {
    // Safe to access
  }
});

3. Forgetting That Reset Recreates Everything

When reset is called, the boundary destroys and recreates all content. This means:

  • All component instances are destroyed and recreated
  • All local state is lost
  • All effects are torn down and re-run
  • All subscriptions are cancelled and re-established

If you have state that should persist across resets, hoist it above the boundary:

<script>
	// This state survives resets
	let userPreferences = $state({ theme: 'light', fontSize: 14 })
</script>

<svelte:boundary>
	<!-- This component can fail and reset without losing preferences -->
	<Editor preferences={userPreferences} />

	{#snippet failed(error, reset)}
		<button onclick={reset}>Reload editor</button>
	{/snippet}
</svelte:boundary>

4. Infinite Reset Loops

If the cause of an error persists, clicking “retry” will just cause the same error again, potentially frustrating users with infinite failure cycles:

<script>
	let retryCount = $state(0)
	const MAX_RETRIES = 3

	function handleRetry(reset) {
		retryCount += 1
		if (retryCount < MAX_RETRIES) {
			reset()
		}
	}
</script>

<svelte:boundary>
	<UnstableComponent />

	{#snippet failed(error, reset)}
		{#if retryCount < MAX_RETRIES}
			<button onclick={() => handleRetry(reset)}>
				Retry ({MAX_RETRIES - retryCount} attempts remaining)
			</button>
		{:else}
			<div>
				<p>This component is currently unavailable.</p>
				<button
					onclick={() => {
						retryCount = 0
						reset()
					}}
				>
					Try fresh
				</button>
			</div>
		{/if}
	{/snippet}
</svelte:boundary>

5. Not Handling Errors in onerror

If your onerror handler itself throws, the error propagates to the parent boundary:

<script>
	function handleError(error, reset) {
		// This could throw if analytics service is unavailable
		analyticsService.trackError(error) // Dangerous!

		// Instead, wrap in try-catch:
		try {
			analyticsService.trackError(error)
		} catch (trackingError) {
			console.error('Failed to track error:', trackingError)
		}
	}
</script>

Testing Error Boundaries

Reliable error handling requires thorough testing. Here’s how to test your boundaries effectively:

<!-- ThrowingComponent.svelte - A test utility -->
<script>
	let { shouldThrow = false, errorMessage = 'Test error' } = $props()

	if (shouldThrow) {
		throw new Error(errorMessage)
	}
</script>

<div>Normal content</div>
// ErrorBoundary.test.js
import { render, screen, fireEvent } from '@testing-library/svelte'
import { describe, it, expect, vi } from 'vitest'
import BoundaryTest from './BoundaryTest.svelte'

describe('Error Boundary', () => {
	it('renders children when no error occurs', () => {
		render(BoundaryTest, { props: { shouldThrow: false } })
		expect(screen.getByText('Normal content')).toBeInTheDocument()
	})

	it('renders fallback when error occurs', () => {
		// Suppress error logging during test
		const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

		render(BoundaryTest, { props: { shouldThrow: true } })
		expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
		expect(screen.queryByText('Normal content')).not.toBeInTheDocument()

		consoleSpy.mockRestore()
	})

	it('recovers when reset is called', async () => {
		const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

		const { component } = render(BoundaryTest, { props: { shouldThrow: true } })

		// Update props to prevent error on retry
		await component.$set({ shouldThrow: false })

		// Click retry button
		await fireEvent.click(screen.getByRole('button', { name: /retry/i }))

		expect(screen.getByText('Normal content')).toBeInTheDocument()

		consoleSpy.mockRestore()
	})

	it('calls onerror handler with error details', () => {
		const errorHandler = vi.fn()
		const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

		render(BoundaryTest, {
			props: {
				shouldThrow: true,
				errorMessage: 'Custom error',
				onError: errorHandler
			}
		})

		expect(errorHandler).toHaveBeenCalledWith(
			expect.objectContaining({ message: 'Custom error' }),
			expect.any(Function) // reset function
		)

		consoleSpy.mockRestore()
	})
})

Performance Considerations

Error boundaries have minimal performance overhead when no errors occur—they’re essentially transparent wrappers. However, keep these considerations in mind:

Creating many boundaries adds slight memory overhead for storing the boundary state. For lists with many items, consider whether each item truly needs its own boundary or if a single boundary around the list is sufficient.

The pending snippet renders initially, so complex pending UI affects initial load performance. Keep pending states simple or use skeleton screens that match your layout to minimize layout shifts.

When reset is called, all cleanup functions run and components reinitialize. For components with expensive initialization (e.g., WebGL contexts, large data structures), consider caching expensive resources above the boundary.

Conclusion

<svelte:boundary> represents a paradigm shift in how we think about error handling in component-based applications. Rather than letting errors cascade through the component tree, boundaries provide isolation, graceful degradation, and recovery mechanisms that keep applications functional even when individual components fail. This architectural approach—thinking in terms of failure domains and blast radius containment—makes applications more resilient and provides better user experiences.

The patterns explored here—hierarchical boundaries, reusable boundary components, centralized error management, and integration with SvelteKit—demonstrate that error handling is not an afterthought but a first-class concern in application architecture. By combining the declarative power of <svelte:boundary> with thoughtful architectural decisions, you can build applications that fail gracefully, recover intelligently, and maintain user trust even in the face of unexpected errors.

Key Takeaways

  • <svelte:boundary> provides declarative error isolation introduced in Svelte 5.3.0, catching rendering errors and effect failures while allowing the rest of the application to continue functioning normally
  • Three main properties control behavior: pending snippet for initial async loading states, failed snippet for error fallback UI with error and reset function arguments, and onerror handler for programmatic error handling and analytics tracking
  • The pending snippet only appears during initial resolution, not during subsequent updates—use $effect.pending() rune to track ongoing promise states within a boundary
  • The reset function completely destroys and recreates boundary contents, clearing all local state, re-executing network requests, and re-running effects from scratch—hoist persistent state above the boundary
  • Hierarchical boundary strategy prevents cascade failures by placing boundaries at multiple granularities: application-level for catastrophic errors, section-level for feature isolation, and component-level for fine-grained protection
  • Error escalation via rethrowing in onerror handlers propagates errors to parent boundaries, enabling selective handling of recoverable errors while escalating critical failures
  • SvelteKit’s route-level error handling operates separately from <svelte:boundary>, catching load function and SSR errors before client code runs, while boundaries handle runtime client-side errors
  • Common pitfalls include over-broad boundaries, assumptions about effect timing, forgetting reset destructiveness, infinite reset loops from persistent errors, and unhandled exceptions in onerror handlers

See Also