This page zooms into one control inside the broader Vue & Svelte Global Error Handlers guide: the svelte:boundary element that Svelte 5 shipped to close a long-standing gap in the framework’s crash-recovery story.

The exact failure this page solves #

Before Svelte 5, a component that threw during render had no local recovery path. Svelte’s runtime unwound the failing component tree and, depending on where the throw originated, could take the entire mounted app down with it — a single malformed API response feeding a chart component was enough to blank the whole page. Framework peers had addressed this years earlier: React shipped class-based error boundaries, and Vue exposed onErrorCaptured for the same purpose (see handling async errors in Vue 3 with onErrorCaptured for the Vue equivalent). Svelte 5 closes the gap with svelte:boundary, a template element that wraps a subtree, renders a failed snippet in its place when that subtree throws during render or inside an effect, and exposes an (error, reset) pair so you can log the failure and let the user retry without reloading the page.

This page covers exactly that mechanism: how to declare a boundary, how the failed snippet and reset() interact, and — just as importantly — which categories of error the boundary does not catch, so you don’t build a false sense of safety around it.

Zero-to-working implementation #

The component below wraps a widget that occasionally throws during render (for example, an assertion on malformed props). It logs the failure, shows a fallback with a retry button, and forces a clean remount on retry.

<!-- WidgetHost.svelte -->
<script>
  import RiskyWidget from './RiskyWidget.svelte';

  // Bumping `key` forces Svelte to destroy and recreate the failed
  // subtree on retry, instead of re-rendering the same broken instance.
  let key = $state(0);
  let crashCount = $state(0);

  function handleError(error, reset) {
    crashCount += 1;
    console.error('[WidgetHost] boundary caught:', error);
    reportToTelemetry({ message: error.message, crashCount });
    // Intentionally NOT calling reset() here — the failed snippet's
    // button drives the retry, so the user controls recovery timing.
  }

  function reportToTelemetry(payload) {
    fetch('/api/client-errors', {
      method: 'POST',
      body: JSON.stringify(payload),
    }).catch(() => {}); // telemetry must never itself throw
  }
</script>

<svelte:boundary onerror={handleError}>
  {#key key}
    <RiskyWidget />
  {/key}

  {#snippet failed(error, reset)}
    <div class="widget-fallback" role="alert">
      <p>This widget crashed: {error.message}</p>
      <button
        onclick={() => {
          key += 1; // force a fresh RiskyWidget instance
          reset();  // clear the boundary's failed state
        }}
      >
        Retry
      </button>
    </div>
  {/snippet}
</svelte:boundary>

Copy this as-is, swap in your own risky child, and the step-by-step explanation below maps each piece back to the code.

Step-by-step explanation #

  1. Wrap the risky subtree, not the page. <svelte:boundary> sits directly around RiskyWidget, not at the app root. Only the wrapped subtree is replaced by the failed snippet on error, so the rest of the page — navigation, layout, sibling widgets — keeps running.

  2. The failed snippet is the fallback contract. Declared as a child of the boundary, it receives (error, reset) as arguments. Anything you render inside it — the error message, a retry button, a support link — becomes the visible state the moment the wrapped content throws.

  3. onerror is for side effects, not rendering. The handleError function fires once per catch, before the failed snippet paints. Use it to log to the console and forward structured data to telemetry; do not use it to mutate UI state that the snippet itself should own.

  4. reset() clears the boundary’s failed flag but does not remount by itself. Calling reset() tells the boundary to try rendering the wrapped content again using its existing state. If the same broken props or corrupted local state are still in place, the identical error can fire again on the very next render pass.

  5. The key bump forces a genuine retry. Incrementing key inside the {#key key} block destroys the old RiskyWidget instance and creates a new one from scratch, so reset() is retrying against a clean slate rather than the exact state that just crashed.

  6. Non-render errors need a separate net. The boundary’s catch is scoped to the render pass and $effect execution. Errors thrown from onclick, onsubmit, or inside a setTimeout/await callback happen outside that pass and must be handled with try/catch or a global window.onerror / unhandledrejection listener, exactly as the Vue & Svelte Global Error Handlers guide covers for the rest of the framework surface.

Edge cases #

Scenario Behavior Mitigation
Error thrown inside an onclick handler Boundary does not catch it; error reaches the browser’s default window.onerror path Wrap handler bodies in try/catch, or route them through a shared global handler alongside the component isolation techniques used elsewhere in the app
await rejects inside an async function called from a component Boundary does not catch it; surfaces as an unhandled rejection Catch explicitly at the call site, similar to the pattern in catching unhandled promise rejections globally in React
reset() called without a key bump Same broken instance re-renders; error can recur on the next tick Pair reset() with a state or key change so the retry starts from a clean instance
Nested svelte:boundary elements The nearest ancestor boundary catches first; outer boundaries never see the error Scope boundaries at the granularity you want isolated — see choosing a boundary granularity strategy
Error during server-side rendering Boundary catches it identically and emits the failed snippet’s markup directly in the SSR response Keep the failed snippet free of client-only APIs (window, document) so it renders safely on the server

Verification steps #

  1. Force a render-time throw. Add a temporary if (!props.data) throw new Error('missing data') inside RiskyWidget and omit the prop. Confirm the failed snippet appears in place of the widget while the rest of the page stays interactive.

  2. Exercise the retry path. Click the Retry button and confirm, via a console.log in RiskyWidget’s script, that a brand-new component instance mounts rather than the same instance re-rendering — this confirms the key bump is actually forcing a remount.

  3. Prove the coverage gap. Move the same throw into an onclick handler instead of the render path. Confirm the boundary’s failed snippet does not appear and that the error instead surfaces in the console via the browser’s default unhandled-error reporting, matching the edge case above.

  4. Check the SSR path. Render the page server-side with the throwing condition active and view the raw HTML response (curl or “View Source”). Confirm the fallback markup from the failed snippet is present in the initial payload, not just injected after hydration.

  5. Confirm telemetry fires once per crash. Watch the Network tab for the /api/client-errors request while repeating the crash-and-retry cycle; the count of POST requests should match the number of times the boundary actually caught an error, not the number of retries clicked.

Frequently asked questions #

Does svelte:boundary catch errors thrown inside an onclick handler?

No. The boundary only catches errors thrown synchronously during a component’s render pass or inside its effects. An error thrown from a DOM event handler runs outside that render pass entirely, so it bypasses the boundary and reaches the browser’s default global error handling instead — the same gap that makes a paired window.onerror listener necessary.

What happens if the error recurs immediately after calling reset?

Calling reset() clears the boundary’s failed flag and re-renders the wrapped content using whatever state it already had. If that state — corrupted props, a stale store value — is unchanged, the identical error can throw again on the next render, making the retry look inert. Pairing reset() with a key increment on the wrapped subtree forces a fresh component instance so the retry isn’t repeating the exact conditions that just failed.

Does svelte:boundary work the same way during server-side rendering?

Yes, with one nuance: during SSR the boundary catches the same render-time errors and writes the failed snippet’s markup straight into the server-rendered response, so the user’s first paint already shows the fallback rather than a blank subtree. If the client subsequently hydrates and the same code path throws again, the boundary on the client catches it independently — the two catches are not linked.