Ch. 3 · React

Where to Put React Suspense Boundaries and Avoid Waterfalls

Where to place React Suspense boundaries: what suspends, how fallbacks reveal, avoiding spinner waterfalls, and how transitions keep old content.

~6 min readadvancedupdated Oct 6, 2026

“Where would you put Suspense boundaries on this page, and what happens when a component inside one suspends?” Interviewers increasingly ask this as React applications move to Suspense-enabled data loading in Next.js, Remix, Relay and React 19’s use. It is a design question disguised as an API question: boundaries decide what the user sees while data is loading, and who owns each loading state.

A weak answer describes <Suspense fallback={<Spinner />}>. A strong answer talks about reveal order, waterfalls, transitions and the relationship between Suspense and error boundaries.

Before you start

You should know component composition, promises and the basics of lazy loading with React.lazy (see lazy code splitting). Data fetching with Suspense requires a Suspense-enabled source: a framework loader, a library such as TanStack Query in suspense mode, Relay, Server Components, or React 19’s use(promise) with a promise created outside render or cached. React 18 supported Suspense for lazy and framework data; React 19 added use as a general way to read promises.

The short answer

A component suspends when it reads something that is not ready, such as a lazy component that has not loaded or a promise passed to use that is still pending. React then shows the fallback of the nearest <Suspense> boundary above it, and replaces the whole boundary’s content when everything inside is ready. Boundaries are therefore units of loading: content inside one boundary appears together. Place them around parts of the page that make sense to reveal independently, avoid wrapping each tiny component, and use transitions so that navigations keep showing the old screen instead of falling back to a spinner.

How it works

import { Suspense, use } from 'react';

function Comments({ commentsPromise }) {
  const comments = use(commentsPromise); // suspends while pending
  return comments.map(c => <p key={c.id}>{c.text}</p>);
}

function Post({ post, commentsPromise }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <Suspense fallback={<p>Loading comments</p>}>
        <Comments commentsPromise={commentsPromise} />
      </Suspense>
    </article>
  );
}
JSX

The post renders immediately. Comments suspends, so React shows “Loading comments” in its place. When the promise resolves, React retries rendering the boundary’s children and swaps the fallback for the real content. If the promise rejects, the error goes to the nearest error boundary.

The promise must be stable across renders. Creating it inside the component (use(fetch(url))) makes a new promise on each attempt, which suspends forever. Create it in a loader, a parent, a Server Component, or a cache.

On the server, streaming SSR sends the fallback HTML first and streams in each boundary’s content as it resolves. On the client, boundaries also allow selective hydration.

Step-by-step walkthrough

Step 1: Sketch the page in loading order

Draw the page and mark what the user needs first. For a product page: the header and product details are critical, reviews and recommendations can arrive later. That sketch becomes your boundary layout.

Step 2: Group things that should appear together

<Layout>
  <ProductDetails product={product} />
  <Suspense fallback={<ReviewsSkeleton />}>
    <ReviewSummary productId={id} />
    <ReviewList productId={id} />
  </Suspense>
  <Suspense fallback={<RecsSkeleton />}>
    <Recommendations productId={id} />
  </Suspense>
</Layout>
JSX

The review summary and list share a boundary, so they never appear out of sync (a star average without the reviews below it). Recommendations reveal independently.

Step 3: Nest boundaries for progressive reveal

An outer boundary can show a page skeleton while an inner boundary handles slower details:

<Suspense fallback={<PageSkeleton />}>
  <Profile userId={id} />
  <Suspense fallback={<PostsSkeleton />}>
    <Posts userId={id} />
  </Suspense>
</Suspense>
JSX

The profile appears first, then the posts. React reveals nested content in order, from outer to inner.

Step 4: Use transitions so navigation does not flash a fallback

When a boundary has already shown content and an update causes a child to suspend, React would normally replace that content with the fallback. Wrapping the update in startTransition tells React to keep the current UI visible while the new one loads:

const [isPending, startTransition] = useTransition();

function selectTab(next) {
  startTransition(() => setTab(next));
}
JSX

Use isPending to dim the old content or show a small inline indicator. Most routers already wrap navigations in transitions. See transitions and urgent updates.

Worked scenario

A dashboard had a single <Suspense> at the root around six widgets. Every page load showed a full-screen spinner for as long as the slowest widget (a three-second analytics query) took, even though five widgets were ready in 200 ms. A redesign then wrapped every widget, every card header and every avatar in its own boundary, and users complained about a “popcorn” effect: dozens of spinners resolving in random order, shifting layout each time.

The final design used three boundaries, matching how users read the page:

<Suspense fallback={<KpiRowSkeleton />}>
  <KpiRow />
</Suspense>
<Suspense fallback={<ChartsSkeleton />}>
  <RevenueChart />
  <SignupsChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
  <AnalyticsTable />
</Suspense>
JSX

The key numbers appear almost immediately, the charts reveal together, and the slow table no longer holds anything else back. Skeletons sized like the real content prevented layout shift. Promises for all three were started in the route loader at the same time, which avoided a waterfall where the table’s request began only after the charts finished.

Common mistake

  • Creating the promise during render. It is new on each attempt, so the component suspends forever. Cache it or create it outside.
  • One boundary for the whole app. The slowest data holds the entire screen hostage.
  • A boundary around every component. Popcorn loading and layout shift.
  • Fetching inside children sequentially. A parent that suspends before its child starts fetching creates a waterfall. Start requests in parallel, as high as possible.
  • Thinking Suspense catches errors. Rejected promises go to error boundaries; pair them so each loading region also has a failure state.
  • Using an effect with isLoading state and calling it Suspense. Effects do not suspend; only Suspense-enabled sources do.

Verify the behavior

Create a controllable promise and watch the fallback swap:

let resolve;
const slow = new Promise(r => { resolve = r; });

function Slow() {
  const value = use(slow);
  return <p>{value}</p>;
}

export default function App() {
  return (
    <>
      <button onClick={() => resolve('ready')}>resolve</button>
      <Suspense fallback={<p>waiting</p>}>
        <Slow />
      </Suspense>
    </>
  );
}
JSX

The page shows “waiting” until you click “resolve”, then “ready”. The button stays interactive throughout because it is outside the boundary. In Chrome DevTools, throttle the network and record with React DevTools’ Profiler to see each boundary resolve; React DevTools can also force a boundary into its fallback state for styling skeletons.

Follow-up questions

What does use add in React 19? It reads a promise or context during render and can be called conditionally. With a pending promise, it suspends the component.

What happens to state inside a boundary when it falls back? On the first load there is no state yet. On updates, React hides the existing content rather than unmounting it, so state is preserved when it reappears.

How does Suspense help hydration? Each boundary can hydrate independently, and React prioritises the boundary the user clicks. See hydration consistency.

Can useDeferredValue avoid fallbacks? Yes. Rendering with a deferred value keeps the old result visible while the new one suspends in the background.

Interview exercise

A search page wraps its results list in <Suspense fallback={<Spinner />}>. Every keystroke replaces the visible results with a spinner. How would you keep old results visible while new ones load, and how would you indicate that they are stale?

Answer and reasoning

The boundary falls back because each new query makes the results component suspend with no transition. Pass the query through useDeferredValue: the input updates immediately with query, while the results render with deferredQuery. When the deferred render suspends, React keeps showing the previous results instead of the fallback. Compare query !== deferredQuery to know the results are stale, and apply reduced opacity to the list. Alternatively, wrap the query state update for the results in startTransition and use isPending. Either way, the spinner only appears on the very first load, when there is nothing older to show. The deferred results feedback note covers the stale indicator in detail.

Continue learning

Continue with the React interview questions and the React MCQs. Related notes: error boundary scope, transitions and urgent updates and lazy code splitting. Official reference: the Suspense reference on react.dev.

More in React

read ✓React · mid

React lazy Loading and Code Splitting

Split React bundles with lazy and Suspense, preload chunks before the click, and handle the chunk-load failures that follow every deploy.

~6 min readread →
read ✓React · hard

React Error Boundaries and Recovery Scope

What React error boundaries catch and miss, where to place them so one failing widget does not blank the page, and how to reset after errors.

~6 min readread →
esc