Overview

Suspense is the React 19 primitive for declarative loading states. A <Suspense> boundary catches any descendant that throws a promise (a server component awaiting data, a client component calling use()) and renders the fallback until the promise resolves. Transitions are the partner primitive for stale-while-fresh UI: a navigation or filter update that should not flash a spinner. This page covers boundary placement, fallback design, and the SSR streaming model that ties both together.

Place boundaries where the user expects a wait

A Suspense boundary is a UI commitment: this region may show a fallback. Place a boundary at every point in the tree where waiting is acceptable, and only at those points.

<Page>
  <Header />                              {/* loads with the shell */}
  <Suspense fallback={<TableSkeleton />}>
    <ProductTable />                      {/* streams in */}
  </Suspense>
  <Suspense fallback={<SidebarSkeleton />}>
    <Recommendations />                   {/* streams independently */}
  </Suspense>
</Page>

One boundary per independent slow region. Wrapping the whole page in one boundary forces the user to wait for the slowest fetch before they see anything.

Design fallbacks that match the final layout

A good fallback occupies the same box as the resolved content. Same width, same height, same vertical rhythm. Reserve the space; the page must not jump when the data arrives.

  • Use a skeleton shape, not a spinner, for content boxes; a spinner reads as “broken” past 300ms.
  • Set explicit dimensions on images and media in the fallback so core-web-vitals CLS stays at zero.
  • For lists, render four-to-six skeleton rows; the real list will be longer, but the user sees structure immediately.
  • Keep the fallback text-free unless the wait is genuinely long (over two seconds). Random labels read as noise.

Nest boundaries to control reveal order

Boundaries compose. A nested boundary hides only its own subtree; the outer fallback covers the rest. Use nesting to stream the page top-down.

<Suspense fallback={<PageSkeleton />}>
  <ArticleHeader />
  <Suspense fallback={<BodySkeleton />}>
    <ArticleBody />
    <Suspense fallback={<CommentsSkeleton />}>
      <Comments />
    </Suspense>
  </Suspense>
</Suspense>

The header renders first, then the body, then the comments. Each region streams independently, and a slow comments fetch never blocks the article. See react-server-components for the server-side data-fetching pattern this enables.

Use useTransition for user-initiated updates

A transition tells React: this state update is non-urgent; keep the old UI interactive until the new one is ready. Wrap a setter call in startTransition, and the component does not flash a fallback during the update.

const [isPending, startTransition] = useTransition();
function selectTab(id: string) {
  startTransition(() => setActiveTab(id));
}
return <Tabs activeTab={activeTab} pending={isPending} onSelect={selectTab} />;

The isPending flag drives subtle UI: a fade on the old content, a disabled state on the trigger. Without a transition, the suspended descendant unmounts and the user sees a fallback.

Use useDeferredValue for derived expensive UI

Reach for useDeferredValue when an input drives an expensive render that should not block typing. The hook returns a value that lags behind the input by one render; React paints the input first, then catches up.

const [query, setQuery] = useState("");
const deferred = useDeferredValue(query);
return (
  <>
    <input value={query} onChange={(e) => setQuery(e.target.value)} />
    <ExpensiveResults query={deferred} />
  </>
);

The difference from useTransition: a transition wraps the setter at the call site; a deferred value wraps the consumed value. Use useTransition when you control the state update. Use useDeferredValue when the value comes from elsewhere (props, context) and you cannot wrap the setter.

Stream HTML with SSR, hydrate at the boundary

In Next.js 15 and any framework on the new pipeline, the server streams HTML in chunks. The shell renders immediately; each Suspense boundary streams its content when ready. Hydration is selective: React attaches event handlers to the shell as soon as it arrives, and to each region as that region streams in.

This makes the placement decision matter. A boundary close to the root delays interactivity for everything inside it. A boundary close to the leaf delays only the slow region. Default to leaf-ward boundaries; move them up only when the parent depends on the child’s data shape.

Pair Suspense with error boundaries

Suspense handles the loading state. It does not handle errors. A fetch that rejects throws past the Suspense boundary to the nearest error boundary. Pair every Suspense region with an error boundary so a failed fetch shows a recovery UI, not a blank screen. See react-error-boundaries for the patterns, and nextjs for the error.tsx and loading.tsx convention that wires both in by file.