EdgeCases Logo
Feb 2026
React
Deep
7 min read

React Suspense Error Boundaries: Recovery Patterns

Errors inside Suspense don't always reach your ErrorBoundary. Master propagation rules, SSR behavior, and recovery patterns for resilient React apps.

react
suspense
error-boundary
ssr
recovery
edge-case
error-handling

Errors inside Suspense trees don't behave like normal React errors. They can bypass the nearest error boundary, reset unexpectedly, or leave your app in a state where recovery seems impossible. Understanding propagation rules is essential for resilient data-fetching UIs.

The Basic Model: Error Boundaries Catch Suspense Errors

When a component inside <Suspense> throws an error (not a promise), React propagates it to the nearest ErrorBoundary:

<ErrorBoundary fallback={<Error />}>
  <Suspense fallback={<Loading />}>
    <DataComponent /> {/* throws Error */}
  </Suspense>
</ErrorBoundary>

If DataComponent throws during render, the error propagates up past Suspense to the ErrorBoundary. Suspense only catches promises, not errors.

The SSR Twist: Different Behavior on Server vs Client

Server-side rendering changes propagation rules significantly:

// Server: Component throws during SSR
<Suspense fallback={<Skeleton />}>
  <AsyncComponent /> {/* throws on server */}
</Suspense>

On the server, if a component throws, React includes the Suspense fallback in the HTML (not the error boundary's fallback). The client then attempts to render the same component. If it errors on the client too, then the ErrorBoundary catches it.

// Server behavior:
// 1. AsyncComponent throws
// 2. Server sends Suspense fallback (Skeleton) in HTML
// 3. Client hydrates, retries AsyncComponent
// 4. If client also throws → ErrorBoundary catches

This two-phase error handling means your error might only appear after hydration, and the server logs may not reflect what the user sees.

Resetting Error Boundaries: The Key Challenge

Once an ErrorBoundary catches an error, its subtree is replaced with the error fallback. But how do you recover? The boundary needs to reset and re-render the children.

Pattern 1: Key-Based Reset

function App() {
  const [errorKey, setErrorKey] = useState(0);

  return (
    <ErrorBoundary
      key={errorKey}
      onReset={() => setErrorKey(k => k + 1)}
      fallback={({ resetErrorBoundary }) => (
        <button onClick={resetErrorBoundary}>Retry</button>
      )}
    >
      <Suspense fallback={<Loading />}>
        <DataComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

Changing the key forces React to unmount and remount the ErrorBoundary, clearing its error state. Libraries like react-error-boundary provide resetErrorBoundary helpers.

Pattern 2: State-Based Reset with resetKeys

<ErrorBoundary
  resetKeys={[userId, query]}
  onResetKeysChange={() => {
    // Clear cache, reset state, etc.
  }}
>
  <UserData userId={userId} />
</ErrorBoundary>

When any value in resetKeys changes, the boundary automatically resets. Useful for data-fetching scenarios where new inputs should clear previous errors.

Suspense + Transitions: Errors Stay Hidden

startTransition prevents Suspense from showing fallbacks during updates. But this also affects error propagation:

function SearchResults() {
  const [query, setQuery] = useState('');
  
  return (
    <>
      <input onChange={e => {
        startTransition(() => setQuery(e.target.value));
      }} />
      <ErrorBoundary fallback={<Error />}>
        <Suspense fallback={<Loading />}>
          <Results query={query} />
        </Suspense>
      </ErrorBoundary>
    </>
  );
}

If Results throws during a transition, React keeps showing the previous content rather than immediately switching to the error boundary. The error eventually propagates, but timing surprises users expecting instant feedback.

The "use" Hook and Promise Rejection

React's use hook reads promises during render. Rejected promises throw:

function DataComponent({ dataPromise }) {
  const data = use(dataPromise); // throws if rejected
  return <div>{data.name}</div>;
}

The thrown error propagates like any render error. But there's a subtle issue:

// ❌ Bug: New promise on every render
function Parent() {
  return (
    <Suspense fallback={<Loading />}>
      <DataComponent dataPromise={fetchData()} />
    </Suspense>
  );
}

If fetchData() returns a new promise each render, Suspense never settles. If that promise rejects, you might see infinite error loops:

  1. Promise rejects → error thrown
  2. ErrorBoundary catches, re-renders parent
  3. Parent creates new promise → suspends again
  4. New promise rejects → repeat

Cache promises at a stable reference (React Query, SWR, or a cache() wrapper).

Nested Boundaries: Granular Recovery

Place error boundaries strategically for isolated recovery:

<ErrorBoundary fallback={<PageError />}>
  <Header />
  <Suspense fallback={<MainSkeleton />}>
    <ErrorBoundary fallback={<ContentError />}>
      <MainContent />
    </ErrorBoundary>
  </Suspense>
  <ErrorBoundary fallback={<SidebarError />}>
    <Suspense fallback={<SidebarSkeleton />}>
      <Sidebar />
    </Suspense>
  </ErrorBoundary>
</ErrorBoundary>

Sidebar errors don't take down MainContent. Each section can retry independently. The outer boundary catches errors that escape inner ones.

ErrorBoundary Inside vs Outside Suspense

// Option A: ErrorBoundary outside Suspense
<ErrorBoundary>
  <Suspense fallback={<Loading />}>
    <Data />
  </Suspense>
</ErrorBoundary>

// Option B: ErrorBoundary inside Suspense
<Suspense fallback={<Loading />}>
  <ErrorBoundary>
    <Data />
  </ErrorBoundary>
</Suspense>

Option A: Error replaces the entire Suspense region. Option B: Error shows inside the Suspense container; other suspended siblings can still resolve. Choose based on your UI recovery needs.

TanStack Query / React Query Integration

When using suspense: true with React Query, errors behave differently:

const { data } = useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
  suspense: true,
  useErrorBoundary: true, // Required for error propagation
});

Without useErrorBoundary: true, React Query catches errors internally and returns them via the error property instead of throwing.

Reset with QueryErrorResetBoundary:

import { QueryErrorResetBoundary } from '@tanstack/react-query';

<QueryErrorResetBoundary>
  {({ reset }) => (
    <ErrorBoundary onReset={reset} fallback={...}>
      <Suspense>
        <UserData />
      </Suspense>
    </ErrorBoundary>
  )}
</QueryErrorResetBoundary>

The reset function clears the query cache for failed queries, allowing a clean retry when the ErrorBoundary resets.

Event Handlers: Errors Don't Propagate

ErrorBoundaries only catch errors during render. Event handlers are outside the React render cycle:

function Form() {
  const handleSubmit = async () => {
    throw new Error('Submit failed'); // ❌ Not caught by ErrorBoundary
  };

  return <button onClick={handleSubmit}>Submit</button>;
}

Use local state for event handler errors:

function Form() {
  const [error, setError] = useState(null);
  
  if (error) throw error; // Re-throw during render → caught

  const handleSubmit = async () => {
    try {
      await submit();
    } catch (e) {
      setError(e); // Triggers re-render → throws
    }
  };

  return <button onClick={handleSubmit}>Submit</button>;
}

Production Recovery Checklist

  • Cache promises to prevent infinite error loops with use
  • Use resetKeys to auto-reset on input changes
  • Nest boundaries for isolated, granular recovery
  • Handle SSR knowing errors may only surface after hydration
  • Re-throw event errors via state to reach boundaries
  • Integrate with data libraries using their boundary helpers

Key Takeaways

  • Suspense catches promises; ErrorBoundaries catch thrown errors—both needed for resilient UIs
  • SSR errors show Suspense fallback first, then propagate to ErrorBoundary on client hydration
  • Use key or resetKeys to reset error boundaries and retry rendering
  • Transitions can delay error visibility by keeping previous content on screen
  • Event handler errors require manual re-throwing via state to reach boundaries
  • React Query/TanStack Query need explicit useErrorBoundary: true for propagation

Advertisement

Related Insights

Explore related edge cases and patterns

React
Deep
React Concurrent Rendering Tearing: When External Stores Break
7 min
React
Deep
React useId: Why Math.random() Breaks Hydration
7 min

Advertisement