Suspense Boundaries

TL;DR / Suspense boundaries are React components that catch thrown promises from their children, display a fallback UI while async work resolves, and reveal the content when ready -- enabling declarative loading states.

How It Works

 ┌────────────┐        ┌────────────┐        ┌───────────────┐
 │ <Suspense> │        │   Child    │        │   Pending:    │
 │ fallback=  │───────→│   throws   │───────→│ show fallback │
 │ <Spinner/> │        │  Promise   │        └───────────────┘
 └────────────┘        └────────────┘                │
                                                     │
                                                 resolves
                                                     ↓
                                             ┌───────────────┐
                                             │   Resolved:   │
                                             │  show child   │
                                             └───────────────┘

Edit diagram

Suspense transforms loading states from imperative spaghetti (if (loading) return <Spinner />) into a declarative boundary that any descendant component can trigger. The parent decides where to show a loading indicator; the child decides when it needs to wait. This separation of concerns is fundamental to composable async UIs.

The Mechanism

When a component inside a Suspense boundary throws a promise during render, React catches it, shows the fallback UI, and subscribes to the promise. When the promise resolves, React re-renders the subtree. The component renders again, this time successfully (because the data is now available), and React replaces the fallback with the real content.

This "throw a promise" protocol is not something application code typically implements directly. Data fetching libraries (Relay, TanStack Query, SWR, Next.js) and React's own use() hook handle the throwing internally. The component author simply reads data, and if it is not available yet, the library throws on their behalf.

Boundary Placement Strategy

Where you place Suspense boundaries determines the user experience. A single boundary at the page root means the entire page shows a spinner until all data loads. Multiple boundaries around individual components mean each section loads independently, giving progressive disclosure.

The ideal placement follows the visual structure of the page. Wrap independent content sections in their own boundaries so that the header can render while the main content loads, and the sidebar can render while the feed loads. Components that must be seen together should share a boundary -- showing a price without its product name is worse than showing a spinner for both.

Nested Boundaries

Suspense boundaries nest. An inner boundary catches suspensions from its subtree. If the inner boundary is itself suspended (because it has not mounted yet), the outer boundary catches its children's suspensions. This creates a cascade: the outer boundary shows first, then its content renders with inner boundaries showing their own fallbacks, which eventually resolve to their content.

React also supports SuspenseList (experimental) for coordinating the reveal order of sibling Suspense boundaries. You can configure them to reveal in order (top to bottom), together (all at once), or as they resolve (potentially out of order).

Integration With Concurrent Features

Suspense is deeply integrated with React's concurrent rendering. When a transition triggers a suspension, React does not show the fallback immediately. Instead, it keeps the current UI visible and renders the new content in the background. Only when the content is ready (or a timeout expires) does React swap to the new view. This prevents flash-of-loading-state for fast responses.

The useTransition hook returns an isPending flag that lets you show a subtle loading indicator (like a dimmed overlay or progress bar) on the current view while the transition renders. This is visually superior to replacing the entire view with a spinner.

Server-Side Rendering With Suspense

In React 18's streaming SSR, Suspense boundaries define the streaming units. The server renders the page and sends HTML for content outside suspended boundaries immediately. Suspended subtrees are sent as HTML fragments when they resolve, along with inline <script> tags that swap the fallback with the real content. This means the initial HTML response includes fallbacks for slow data, and the final content streams in as it becomes available.

This eliminates the all-or-nothing nature of traditional SSR, where the entire page waits for the slowest data source. With streaming Suspense, fast data renders fast, and slow data streams in later.

Error Handling

Suspense only handles promises (pending states). Rejected promises need error boundaries (componentDidCatch / ErrorBoundary). In practice, you pair Suspense boundaries with error boundaries so that the same visual region handles both loading and error states. Some libraries combine them into a single component (QueryErrorResetBoundary in TanStack Query).

Gotchas

  • Suspense does not fetch data. It handles the loading state. You need a Suspense-compatible data library or the use() hook to actually trigger suspension. Using useEffect for fetching does not integrate with Suspense.
  • Throwing promises in render is the contract, but creating a new promise on every render causes an infinite suspension loop. The promise must be cached and reused across renders (the data library handles this).
  • Fallback flicker: For fast responses, showing a fallback for 50ms creates a worse experience than no fallback at all. Use startTransition to delay the fallback, or set a minimum display time for the fallback to prevent flicker.
  • Suspense boundaries reset their entire subtree when they transition from fallback to content. Component state inside a suspended boundary is lost. If you need preserved state, lift it above the boundary.
  • Every Suspense boundary adds a streaming chunk in SSR. Too many boundaries create excessive HTTP chunks and inline script overhead. Group related content under shared boundaries.