Topic 14.3
Suspense
In one line
<Suspense fallback={...}> shows a fallback while anything inside it is still loading (lazy code, a promise read with use(), a Suspense-enabled library), lets you nest boundaries to control what appears together, and works with transitions to keep the old screen visible instead of flashing a spinner.
Think of it like this
A theatre curtain. While the stage crew is still setting up a scene, the audience sees the curtain, not half-built scenery and stagehands. When the scene is ready, the curtain opens on the finished set. A big show can have smaller curtains too: the main stage opens while a side stage is still closed. <Suspense> is a curtain around part of your page, and the fallback is what's painted on it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Suspense
- A built-in component that shows a fallback while something inside it is still loading.
- Suspend
- When a component can't render yet because it's waiting for code or data, it tells React "not ready".
- Boundary
- A
<Suspense>component and everything inside it, revealed together. - Fallback
- The UI shown in place of a boundary's children while they're loading, such as a skeleton.
- Reveal order
- The order in which boundaries swap their fallback for real content: outer ones first, then inner ones.
- lazy
- A function that loads a component's code only when it's first rendered.
- Transition
- An update marked as non-urgent with
startTransition; React keeps the old screen while it prepares the new one. - Popcorn UI
- A page where many small pieces pop in one by one at random moments, because there are too many boundaries.
Step by step
01Wrap the slow part in a boundary
Choose the part of the page that may not be ready and wrap it. The fallback should look like the content (a skeleton) and take the same space.
<Suspense fallback={<ProfileSkeleton />}>
<Profile userId={id} />
</Suspense>02Something inside suspends
When Profile reads a pending promise with use(), it suspends. React stops rendering that boundary, commits the fallback, and subscribes to the promise. When the promise settles, React renders the boundary's children again.
03Group what belongs together
Header and sidebar in one boundary appear together, when the slower one is ready. Comments in their own inner boundary appear later. The first live example logs the reveal order.
04Split code with lazy
A heavy component (a chart, an editor) loaded with lazy suspends while its file downloads. Put the boundary close to it so the rest of the page doesn't disappear. The second live example fakes a 500 ms download.
05Use a transition when switching views
A plain setTab("posts") hides the current tab and shows the fallback. startTransition(() => setTab("photos")) keeps the current tab visible and sets isPending until the photos are ready. The third live example does both.
const [isPending, startTransition] = useTransition();
function selectTab(next) {
startTransition(() => {
setTab(next); // old tab stays visible until the new one is ready
});
}Try it yourself
- 1
Give the sidebar its own boundary
In the first example, wrap
<Sidebar />in its own<Suspense fallback={<Shown name="sidebar fallback"><p>...</p></Shown>}>. Predict the new order of the log lines before you run it. Now the header no longer waits for the sidebar. - 2
Remove the inner boundary
Delete the inner
<Suspense>around<Comments />(keep<Comments />). Predict: what does the user see for the first second, and how many lines are logged? Everything now waits for the slowest part. - 3
Make the plain tab a transition
In the third example, wrap the Posts button's update in
startTransitiontoo. Predict the first log line. Then move the<Suspense>so it only wraps<Posts />: does a plain click still hide the About page?
Code & diagrams
Header is ready at 300 ms but waits for Sidebar (600 ms): they share a boundary. The comments fallback only appears with them, and comments come last. Press Run again and watch the stages.
Preview
Console
Nothing logged yet.Expected console output
shown: page fallback
shown: header
shown: sidebar
shown: comments fallback
shown: commentsThe code "downloads" only on the first render of `<Chart />`, never at startup. `lazy` calls its loader once and caches the result.
Preview
Console
Nothing logged yet.Expected console output
before click: (nothing)
downloading Chart code...
100 ms after click: Loading chart...
700 ms after click: 📈 ChartThe plain click replaces the visible "About page" with the fallback. The transition keeps "posts loaded" on screen (dimmed, with "Switching...") until the photos are ready.
Preview
Console
Nothing logged yet.Expected console output
plain, 100 ms later: Loading tab...
plain, done: posts loaded
transition, 100 ms later: posts loaded + Switching...
transition, done: photos loaded// app/product/[id]/page.tsx: a Server Component
import { Suspense } from "react";
export default async function ProductPage({ params }) {
const { id } = await params;
const product = await getProduct(id); // the page waits for this
return (
<>
<ProductDetails product={product} />
{/* HTML for the reviews streams in later, when they're ready */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={id} /> {/* an async Server Component */}
</Suspense>
</>
);
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Expect Suspense to catch fetching in an effect
Wrap a component that loads data with useEffect + useState in <Suspense fallback={<p>Loading...</p>}>.
Break #2
Put the boundary too high
Wrap the whole App (header, navigation and all) in a single <Suspense> and switch tabs without a transition.
Myth vs fact
Myth
Suspense fetches data.
Fact
Suspense only shows a fallback while children are not ready. Something else (a cached promise, a library, a framework) starts the request and tells React when it's done.
Myth
Each component that loads data needs its own Suspense.
Fact
One boundary can cover many components, and they appear together. Choose boundaries by what the user should see as a unit.
Myth
Suspense is only for code splitting.
Fact
It started with lazy (React 16.6), but since React 18 and 19 it also covers data (use, frameworks, Suspense libraries) and streaming server rendering.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
React throttles reveals: when content inside a boundary becomes ready shortly after a previous reveal, React may wait a little (about 300 ms) and reveal several boundaries together, to avoid popcorn UI. Don't write code that depends on exact reveal timing.
- ▸
React 19 changed how siblings render when one suspends: React commits the nearest fallback immediately and then "pre-warms" (renders in the background) the suspended siblings, so their lazy code and data requests still start early. In React 18 it rendered all siblings first, which delayed the fallback.
- ▸
Content that was already shown and suspends again is hidden (React sets
display: noneon its DOM) rather than unmounted, so its state survives; its layout effects are cleaned up while hidden and run again when it's shown. That's why the plain-tab example's DOM still contains "About page" while the fallback is visible. - ▸
<Suspense>also controls streaming server rendering: withrenderToReadableStreamor a framework, each boundary's fallback HTML is sent first and the real HTML streams in later, and the boundaries hydrate independently (Topic 20.1). - ▸
useDeferredValue(value)(Topic 16.7) is the other way to avoid fallbacks: render the new input with the old deferred value while the new results suspend in the background, for example a search box whose results list keeps the previous results until the new ones are ready.
Remember this
- 1
What Suspense is.
<Suspense fallback={<Spinner />}>children</Suspense>is a built-in component. When a component inside it can't render yet because something it needs is still loading, the component suspends: it tells React "not ready". React then shows the nearestSuspenseboundary'sfallbackinstead of that boundary's children, and tries rendering the children again when the thing they were waiting for is ready. A boundary is the<Suspense>component and everything inside it. You write the loading UI once, in the parent, instead ofif (loading)in every component. - 2
What can suspend. Only these: (1) code loaded with **
lazy()that hasn't downloaded yet; (2) a promise read withuse(promise)that hasn't settled (React 19, Topic 14.4); (3) Suspense-enabled data libraries and frameworks**: TanStack Query'suseSuspenseQuery, Relay, React Router's<Await>, andasyncServer Components in Next.js (Phase 20). Fetching in an effect does not suspend: the effect runs after render, so the component already rendered its own loading state. Suspense doesn't detect data loading by magic; the reading code must be built for it. - 3
All or nothing inside one boundary. Every component inside a boundary is revealed together. If
Headeris ready after 300 ms andSidebarafter 600 ms and both are in the same boundary, both appear at 600 ms; React never shows half of a boundary. This is a feature: you decide which pieces make sense alone. The fallback of a boundary replaces all of its children, including ones that were ready. - 4
Nested boundaries and reveal order. Put a boundary inside another to let parts appear in stages:
<Suspense fallback={<PageSkeleton />}>around the page, and an inner<Suspense fallback={<CommentsSkeleton />}>around the slow comments. The page appears as soon as its own content is ready; the comments fill in later. An inner boundary can only reveal after its outer boundary has revealed, so the order is always outside first, then inward. Design boundaries around what the user expects to see as a unit: a page shell, then main content, then secondary panels. Too many boundaries make a "popcorn" UI where pieces pop in all over the place. - 5
**Suspense with
lazy.**const Chart = lazy(() => import("./Chart.jsx"))splitsChartinto its own JavaScript file that is downloaded the first time<Chart />renders. While it downloads,Chartsuspends and the nearest boundary shows its fallback. Thelazyloader must return a promise of a module with adefaultexport. After the first load the code is cached, so later renders are instant. Topic 16.8 covers code splitting in depth. - 6
Transitions keep the old UI visible. If content that is already on screen suspends again (the user switches to a tab whose data isn't loaded), React would hide it and show the fallback, which feels jumpy. Wrap the update in **
startTransition** (fromuseTransition, Topic 16.7) and React instead keeps showing the old screen until the new one is ready, whileisPendinglets you show a small hint. Rule of thumb: a boundary's fallback is for the first load; switching between already-loaded views should be a transition. Navigations in React Router and Next.js are transitions by default for this reason.
Explain it without notes
What does Suspense do, and what kinds of things can make a component suspend?
How do nested Suspense boundaries control the reveal order? What happens to two components in the same boundary?
Why can a transition prevent a loading fallback from appearing, and when should you use one?
How does lazy work with Suspense?
Practice
A product page has Details (fast), Reviews (slow) and Recommendations (slowest). Write the JSX so details appear first, and reviews and recommendations each fill in independently.
Load a Settings panel with lazy only when a button is clicked, with a fallback that says Loading settings....
A SearchResults component suspends while results load. Make typing a new query keep the old results visible (dimmed) instead of showing the fallback.
Trade-offs
- ↔
Few boundaries mean fewer loading states but long waits for the slowest part; many boundaries show content sooner but can look chaotic. Start with one per meaningful region and adjust after watching it on a slow network.
- ↔
Transitions avoid jarring fallbacks but can make the app feel unresponsive if nothing changes for a long time. Always show
isPendingfeedback near what the user clicked.
Done when you can
I can explain what suspends and what doesn't.
I can place nested Suspense boundaries to control what appears together.
I can lazy-load a component with a fallback.
I can use a transition to keep the old UI visible while new content loads.
I can predict the reveal order of nested boundaries.