“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>
);
}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>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>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));
}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>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
isLoadingstate 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>
</>
);
}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.