Suspense, the fallback contract
React's Suspense component, the declarative boundary that replaces the loading flag in the App Router, and the senior skill of deciding where to draw it so each piece of UI reveals as its own unit.
A dashboard page reads its data on the server. The component is async, and somewhere in its body sits a line like this:
const invoices = await listInvoices(); // data layer: Unit 5That query takes around 800 milliseconds: not catastrophic, not instant, but long enough to notice. The question this lesson answers is about the user, not the code. For those 800 milliseconds, what fills the screen?
If you have written React before this course, you already have an answer, and it is the wrong one for the App Router. The answer you carry is a loading flag. You reach for a useState(false) called isLoading, flip it to true, kick off a fetch inside a useEffect, and flip it back to false when the data lands. Somewhere in your JSX you write if (isLoading) return <Skeleton />. It works, but it costs you more than it looks.
That flag lives in a Client Component, because useState and useEffect only run on the client. You have to thread it down to wherever the spinner actually shows. And it does not scale. The moment your page loads two independent things, say a profile and an activity feed, you need two flags, or one flag that conflates them. Every new piece of data becomes another small state machine you own and can get wrong, so loading state ends up scattered across the tree, manually wired, and easy to forget.
React’s answer is a single declarative primitive: the <Suspense> component, used in a way you have probably never seen. It replaces the entire flag-and-effect dance. The syntax is the easy part. The skill that takes longer to build is not how to write it but where to draw it, and that placement decision is what the rest of this lesson works toward.
Suspense is a contract, not a hook
Section titled “Suspense is a contract, not a hook”Start with what Suspense actually is, because almost everyone gets the category wrong on first contact. It is not a hook. It is not a config flag you set in next.config. It is not a data-fetching library. It is a built-in React component, used in JSX like any other, with exactly one job.
Here is that job, stated as a contract: while any descendant is still loading, render the fallback; when every descendant has resolved, render the children. That is the whole of it. The component sits in your tree and watches the subtree beneath it for a single signal.
Three properties of that contract clear up the three misconceptions people arrive with.
- Suspense is a component. You place it in JSX and give it a
fallbackprop and somechildren. There is nouseSuspense()and no setup call. - It reacts to a signal that a child throws. When a component beneath the boundary is not ready, it emits a suspend signal , and
<Suspense>catches it the way atry/catchcatches a throw. This means the boundary does not need to know what its children are loading or how. A child reading from a database, a child reading a streamed promise, and a lazily-loaded component all look the same to the boundary. It only knows that something below it said “not yet.” - The rule is all-or-nothing per boundary. The fallback shows while any single child is still suspending, and the children show only once every one of them has resolved. One boundary has exactly two visual states, and it stays in the fallback state until the last holdout settles.
That last property is the one that drives the placement decision later, so keep it in mind: a boundary is a unit, and everything inside it appears together or not at all.
showing — <InvoiceSkeleton />
A child below the boundary said “not yet,” so the fallback is on screen in its place.
showing — <InvoiceList />
- INV-1042 $1,200
- INV-1043 $840
- INV-1044 $2,310
The child finished — that, not any code you wrote, is what flipped the boundary to its children.
Here is the literal shape, so you see it before any nuance: a fallback, and the thing it is guarding.
<Suspense fallback={<InvoiceSkeleton />}> <InvoiceList /></Suspense>InvoiceList here is an async Server Component, and its body does that 800ms await. Until the await settles, InvoiceList has produced no output, so it suspends, and the nearest <Suspense> above it shows <InvoiceSkeleton /> in its place. When the data arrives, React swaps the skeleton out for the list. You wrote no flag and flipped no boolean; the boundary did it.
The piece you supply is the fallback , the UI shown during the wait. The next section shows the two kinds of children that can sit inside the boundary and trigger it.
Declarative beats the loading flag
Section titled “Declarative beats the loading flag”Before going further, it is worth seeing the old way and the new way side by side, because the old way is exactly what Suspense replaces. The two shapes differ in kind, not just in line count.
'use client';
export function InvoiceList() { const [invoices, setInvoices] = useState(null); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { fetch('/api/invoices') .then((res) => res.json()) .then((data) => { setInvoices(data); setIsLoading(false); }); }, []);
if (isLoading) return <InvoiceSkeleton />;
return <ul>{invoices.map(/* … */)}</ul>;}Imperative: you own the loading state machine and have to thread it everywhere. The loading concern is spread across three places: the flag, the effect that flips it, and the branch that reads it. Every independent piece of data needs its own flag, and forgetting to flip one is a whole class of bug.
async function InvoiceList() { const invoices = await listInvoices(); // data layer: Unit 5
return <ul>{invoices.map(/* … */)}</ul>;}
function DashboardPage() { return ( <Suspense fallback={<InvoiceSkeleton />}> <InvoiceList /> </Suspense> );}Declarative: the boundary owns the loading state, and the component just awaits. InvoiceList contains zero loading logic: no flag, no effect, no 'use client'. The <Suspense> boundary, placed by whoever composes the component, is the single place loading is expressed.
Look at what moved. In the first variant, the loading state is the component’s problem: declared with useState, advanced inside an effect, read in a branch, and repeated for every new query. In the second, the component does not mention loading at all. It awaits its data and returns its UI, while the boundary handles the wait. There is no flag to forget to flip, because there is no flag.
The two shapes that suspend
Section titled “The two shapes that suspend”So a child throws a suspend signal and the boundary catches it. But which children actually do that? In 2026 App Router code there are two shapes you will author, and recognizing them is the practical core of using Suspense. Both throw the same signal, which is why a single <Suspense> can sit above either one without caring which it gets.
The first is the one you have already seen.
async function InvoiceList() { const invoices = await listInvoices(); // data layer: Unit 5
return ( <ul> {invoices.map((invoice) => ( <li key={invoice.id}> {invoice.number} — {invoice.total} </li> ))} </ul> );}The component is async. That alone tells React the component may not produce its output synchronously.
async function InvoiceList() { const invoices = await listInvoices(); // data layer: Unit 5
return ( <ul> {invoices.map((invoice) => ( <li key={invoice.id}> {invoice.number} — {invoice.total} </li> ))} </ul> );}It awaits its data in the body. Until this promise settles, the function has not returned any JSX, so there is nothing to render. The component suspends, and the nearest <Suspense> ancestor shows its fallback in the meantime.
async function InvoiceList() { const invoices = await listInvoices(); // data layer: Unit 5
return ( <ul> {invoices.map((invoice) => ( <li key={invoice.id}> {invoice.number} — {invoice.total} </li> ))} </ul> );}Once the data is in, the component returns its UI exactly as a synchronous component would. React swaps the fallback for this output.
The second shape is one you met when you learned that a Server Component can hand a promise to a Client Component. Now you get to see its loading behavior. Here is the setup: a Server Component starts a query but does not await it, passing the unsettled promise down as a prop. A Client Component receives that promise and reads it with use().
// app/dashboard/page.tsx — Server Componentfunction DashboardPage() { const invoicesPromise = listInvoices(); // data layer: Unit 5
return ( <Suspense fallback={<InvoiceSkeleton />}> <InvoiceTable invoicesPromise={invoicesPromise} /> </Suspense> );}
// invoice-table.tsx — Client Component'use client';
type InvoiceTableProps = { invoicesPromise: Promise<Invoice[]> };
export function InvoiceTable({ invoicesPromise }: InvoiceTableProps) { const invoices = use(invoicesPromise);
return <SortableTable rows={invoices} />;}On the server, start the query but don’t await it. You now hold a promise, not data.
// app/dashboard/page.tsx — Server Componentfunction DashboardPage() { const invoicesPromise = listInvoices(); // data layer: Unit 5
return ( <Suspense fallback={<InvoiceSkeleton />}> <InvoiceTable invoicesPromise={invoicesPromise} /> </Suspense> );}
// invoice-table.tsx — Client Component'use client';
type InvoiceTableProps = { invoicesPromise: Promise<Invoice[]> };
export function InvoiceTable({ invoicesPromise }: InvoiceTableProps) { const invoices = use(invoicesPromise);
return <SortableTable rows={invoices} />;}Pass the unsettled promise down as a prop. The server doesn’t block here; it hands the pending work to the client.
// app/dashboard/page.tsx — Server Componentfunction DashboardPage() { const invoicesPromise = listInvoices(); // data layer: Unit 5
return ( <Suspense fallback={<InvoiceSkeleton />}> <InvoiceTable invoicesPromise={invoicesPromise} /> </Suspense> );}
// invoice-table.tsx — Client Component'use client';
type InvoiceTableProps = { invoicesPromise: Promise<Invoice[]> };
export function InvoiceTable({ invoicesPromise }: InvoiceTableProps) { const invoices = use(invoicesPromise);
return <SortableTable rows={invoices} />;}In the Client Component, use() reads the promise. While it’s pending, use() suspends the component, raising the same signal the async Server Component threw.
// app/dashboard/page.tsx — Server Componentfunction DashboardPage() { const invoicesPromise = listInvoices(); // data layer: Unit 5
return ( <Suspense fallback={<InvoiceSkeleton />}> <InvoiceTable invoicesPromise={invoicesPromise} /> </Suspense> );}
// invoice-table.tsx — Client Component'use client';
type InvoiceTableProps = { invoicesPromise: Promise<Invoice[]> };
export function InvoiceTable({ invoicesPromise }: InvoiceTableProps) { const invoices = use(invoicesPromise);
return <SortableTable rows={invoices} />;}The boundary sits above the Client Component and catches that signal. The fallback shows until the promise resolves, then the interactive table renders with its data.
Why would you ever reach for the second shape when the first is simpler? Because the table is interactive: it sorts, it filters, and so it needs to be a Client Component. This pattern lets the interactive shell be a Client Component while the data it displays is still streaming in from the server. The shell and the data have different homes, and use() is the seam between them: it suspends the Client Component on a pending promise and hands back the value the moment it resolves.
Those are the two shapes you will write. For completeness, two other things can also suspend: React.lazy(), used to code-split a component so its code loads on demand, and Suspense-aware hooks from some data libraries. Neither is the 2026 default for fetching data, so they get this mention and no more.
A good fallback mirrors the resolved layout
Section titled “A good fallback mirrors the resolved layout”You write the fallback. Suspense does not generate one for you from the children, because it has no idea what the resolved content will look like. So the quality of the loading experience is entirely on you, and two rules separate a fallback that helps from one that hurts.
The first is a hard constraint, not a preference. The fallback is rendered synchronously and must not itself suspend. The reason follows from what the fallback is for: it is what you show while something is loading. If the fallback also has to load, by awaiting or reading a pending promise, then there is nothing to show during the wait and the boundary has no purpose. Keep fallbacks dumb and instant: static markup, no data reads.
The second rule is where experience shows. Prefer a skeleton that mirrors the final layout: the same heights, the same number of rows, the same rough shape as the content it stands in for. A spinner or a line of “Loading…” text is acceptable, and you will see it everywhere, but it is lower quality for a concrete reason. A spinner occupies a tiny box, so when the real content arrives much larger, everything below it lurches down the page. The content does not fade in; it shoves. A skeleton that already occupies the content’s eventual footprint swaps in place, and nothing moves.
Recent activity
list needs this room — footer is shoved down to here
Recent activity
That jump is not a cosmetic nitpick. Layout shift on data load is a measurable UX defect: it reads as jank, it is jarring on a surface the user hits dozens of times a day, and it is exactly why teams invest in skeletons that match the resolved shape. Reserving the space the content will occupy is what removes the shift.
Drawing the boundary at the unit of UX
Section titled “Drawing the boundary at the unit of UX”You now know what Suspense is, what suspends, and what a good fallback looks like. The decision that takes judgment, and the one this lesson has been building toward, is where the boundary goes.
One diagnostic question carries most of the skill: “What should the user see resolve as a single unit?” Not “where is the code” and not “which component is async.” Ask what the user perceives as one thing arriving, and put a boundary around that.
To see why it matters, take a page with two independent reads: a user-profile read that returns in 10 milliseconds, and an activity-feed read that takes 800. Watch what one boundary does to them.
function DashboardPage() { return ( <Suspense fallback={<DashboardSkeleton />}> <UserProfile /> <ActivityFeed /> </Suspense> );}The user waits the full 800ms to see the 10ms profile too: the slow read holds the fast one back. Because a boundary is all-or-nothing, the profile cannot appear until the activity feed also resolves, so the fast widget is stuck behind the slow one.
function DashboardPage() { return ( <> <Suspense fallback={<ProfileSkeleton />}> <UserProfile /> </Suspense> <Suspense fallback={<FeedSkeleton />}> <ActivityFeed /> </Suspense> </> );}Each read resolves and reveals on its own, so the profile paints almost immediately. Two boundaries means two independent units: the profile shows at 10ms with the feed’s skeleton still beside it, and the feed fills in when it’s ready.
The diagram makes the relationship between boundary count and reveal granularity literal.
1 boundary
1 unitAll-or-nothing: the fast profile is held back until the feed resolves — both appear together, at ~800ms.
2 boundaries
2 unitsTwo independent units: Profile paints at ~10ms, the feed fills in at ~800ms.
So here is the rule, stated cleanly: draw the boundary around the smallest piece of UI that loads as a single concept, such as a widget, a list, or a sidebar card. Two things that resolve independently belong in two boundaries, so each reveals the moment it is ready. Two things that only make sense together, like a chart and the legend that explains it, or a total and the rows it sums, belong in one boundary, because revealing half of a single idea is worse than revealing none of it.
One caveat, so you are not misled. Why two parallel boundaries each reveal separately, the mechanism that lets the server send the fast one first and the slow one later, is the subject of the next lesson, on streaming. Here you are learning only the placement decision, framed as a question about the user. The transport that makes that placement pay off comes next.
Try walking the decision yourself. The drill below poses the questions in the order an experienced engineer asks them, and that order matters as much as any single answer.
The view has no meaning until all the data is in, so reveal it as one unit. Revealing half of a single idea is worse than revealing none of it. The data-side counterpart, fetching them together with Promise.all in a single component, is the next lesson’s job.
They’re independent, so give each its own boundary. Even at similar speeds the cost is small and the reveal stays granular.
Put the slow read behind its own boundary so the fast widgets paint immediately instead of waiting on it. This is the most common real-world case.
Nested boundaries compose into a reveal cascade
Section titled “Nested boundaries compose into a reveal cascade”Boundaries are components, and components nest, so boundaries nest. This is more powerful than it sounds: nesting gives you a content-first reveal, shell then partial then full, without writing a single line of coordinating state.
The model is simple once you hold the all-or-nothing rule in mind. An outer boundary’s fallback covers everything beneath it until its directly-awaited content is ready. Once that outer content renders, an inner boundary takes over for its own slower subtree, showing the inner fallback while everything around it is already on screen. Each boundary minds only its own children.
Watch it unfold in the figure below.
Step 1 of 3 — the entire page is the outer skeleton.
showing — <FeedSkeleton />
Step 2 of 3 — shell + header real, the inner region is still its own skeleton.
showing — <ActivityFeed />
- 2m
- 9m
- 14m
Step 3 of 3 — full content, produced by nesting alone — no orchestration code.
In code, that cascade is just one boundary inside another, with some synchronous content in between:
<Suspense fallback={<PageSkeleton />}> <DashboardHeader /> <Suspense fallback={<FeedSkeleton />}> <ActivityFeed /> </Suspense></Suspense>The outer fallback shows until DashboardHeader is ready. Once it is, the header paints and the inner boundary takes over, showing <FeedSkeleton /> while ActivityFeed finishes its slow read. That is three visual states with zero state variables.
The tool cuts both ways, so one caution: nesting too deeply produces a distracting cascade of skeletons popping in one after another, which feels busier and slower than it is. Nest at meaningful UX seams, such as the shell and then a slow region inside it, not at every component that happens to be async.
Re-suspending on input change with key
Section titled “Re-suspending on input change with key”There is a trap here that surprises every developer the first time, and the fix is one prop. The setup is ordinary: a detail view that re-renders with new props. The user is looking at invoice inv_001, clicks invoice inv_002, and the component re-renders with a new invoiceId.
You would expect the fallback to return while the new invoice loads. It does not. React sees the same component type in the same position in the tree, concludes it is the same instance, reuses the already-resolved subtree, and skips the fallback entirely. So the user keeps seeing invoice inv_001’s data, stale and wrong, for the full duration of the new load, with no loading indication at all. It looks like nothing happened, and then the content silently changes underneath them.
The fix tells React the truth: this is a different thing. Put a key on the suspending subtree, one that changes with the input.
<Suspense fallback={<InvoiceSkeleton />}> <InvoiceDetail invoiceId={invoiceId} /></Suspense>React reuses the resolved tree on a prop change: no fallback, stale content. When invoiceId changes, React keeps the old resolved subtree mounted and shows the previous invoice until the new data quietly arrives.
<Suspense fallback={<InvoiceSkeleton />}> <InvoiceDetail key={invoiceId} invoiceId={invoiceId} /></Suspense>A changed key is a fresh mount, so the boundary suspends again and the fallback returns. The new key tells React this is a different instance, so it remounts, re-suspends, and shows the skeleton until the new invoice resolves.
A changed key means a fresh mount, a fresh mount means a fresh suspend, and a fresh suspend means the fallback comes back. This is the clean way to say “show a loading state when the route parameter changes,” with no isLoading flag reintroduced and no effect, just an identity hint.
There is a deliberate opposite, named once so you know it exists. Sometimes you don’t want the fallback on a change, as with search-as-you-type or switching tabs, because flashing a skeleton on every keystroke is jarring. For that, you wrap the update in startTransition, which keeps the old content visible and gives you an isPending flag instead of swapping in the fallback. That is the inverse of the key move: key says “this is new, show the loading state,” while a transition says “keep the old, this is not urgent.” The transition hooks have their own React lesson; here, just know that the two pull in opposite directions on purpose.
What Suspense does not do
Section titled “What Suspense does not do”Every misconception in this list is a real bug someone ships, so it is worth drawing the boundary of responsibility explicitly. Suspense has one job, and these are the jobs it does not have.
- It does not catch errors. If a component below the boundary throws an error, an actual error rather than a suspend signal, that error sails straight past Suspense and crashes the tree. Catching it is the job of an Error Boundary, which the App Router exposes through an
error.tsxfile. We cover that, and the “this resource doesn’t exist” case, two lessons from now. Suspense catches a suspend signal, not an error; they are different signals. - It does not retry, and the
fallbackis not an error state. The fallback means “still loading,” full stop. It does not mean “something went wrong,” and Suspense will not re-attempt a failed load for you. - It does not deduplicate fetches. Two children that read the same data fire two requests. Making the second one cheap, through request memoization with
cache(), is a separate mechanism covered when we reach caching. - It does not auto-skeleton. As you saw, you write the
fallback. Suspense has no view into the children’s shape and cannot generate a placeholder from them.
The first one bites hardest: “I wrapped it in Suspense, why did the error still crash the page?” Now you know it is the wrong tool for that signal. The error story has its own lesson.
Practice: place the boundary
Section titled “Practice: place the boundary”You have read the unit-of-UX decision; now make it. Below are two simulated-async widgets sharing a single boundary. Give the slow one its own.
This runs entirely in the browser, with no server and no database. Suspending is faked with a setTimeout-backed promise read by use(), standing in for the async Server Component or streamed promise you’d write in real App Router code. Read it as “this widget takes 50ms or 1500ms to be ready,” and don’t carry the setTimeout shape into production.
Right now App wraps both widgets in one shared <Suspense>. That single boundary is all-or-nothing, so its fallback covers everything until the 1500ms slow read finishes, holding the fast widget back. Split it: give the slow widget its own boundary so the fast one reveals on its own.
Both widgets share one Suspense boundary, so the fast widget is stuck waiting on the slow one. Give the slow widget its own <Suspense> with its own skeleton fallback (SlowSkeleton), and keep the fast widget behind its own boundary with FastSkeleton, so each reveals the moment it's ready.
Reference solution
Each widget gets its own <Suspense> with its own skeleton, so the two reads are independent units. The profile’s boundary resolves at about 50ms and paints while the feed’s boundary is still showing SlowSkeleton, and the feed fills in at about 1500ms. Only App changes; the widgets, skeletons, and promises are untouched.
export function App() { return ( <> <Suspense fallback={<FastSkeleton />}> <UserProfile /> </Suspense> <Suspense fallback={<SlowSkeleton />}> <ActivityFeed /> </Suspense> </> );}SharedSkeleton is now unused, because the single combined fallback is exactly the shape you removed.
Recall check
Section titled “Recall check”Check the model against the misconceptions it replaces.
An async Server Component is still awaiting its data. What is on screen during that wait, and what flips it to the real content?
<Suspense> shows its fallback; the moment the component’s await settles it stops suspending, and React replaces the fallback with the component’s output.false from inside a useEffect.A dashboard runs two independent reads: a profile that comes back in 10ms and an activity feed that takes 800ms. You want the profile on screen the instant it’s ready, not 800ms later. Which arrangement does that?
<Suspense>, each with its own fallback.<Suspense> that shares one fallback.<Suspense> around the page and add an isLoading flag to the profile.isLoading flag is the imperative pattern Suspense exists to replace, and removing the boundary leaves no fallback to show during the wait.An invoice detail view sits behind a <Suspense>. The user clicks from inv_001 to inv_002, the component re-renders with the new invoiceId, and inv_001’s data stays on screen for the full new load — no skeleton in between. Why does the fallback skip, and which one-line change brings it back?
key tied to invoiceId marks it as a new instance, forcing a remount that suspends afresh.isLoading flag in a useEffect that watches invoiceId is what re-shows the skeleton.try/catch lets you replace the old data while the new invoice loads.<Suspense> around the same component restores the fallback on later changes.key that changes with invoiceId is an identity hint: it remounts the subtree, the remount suspends, and the fallback returns. An isLoading flag is exactly the imperative bookkeeping Suspense exists to delete, try/catch is for thrown errors rather than a still-loading state, and a boundary can suspend any number of times — it just never sees a new instance to suspend on here.Each claim is about the boundary of responsibility you just drew — what Suspense does and, more often, what it does not. Mark each statement True or False.
If a component below a <Suspense> boundary throws an error, the boundary catches it and shows the fallback.
error.tsx (two lessons from now). The fallback means “still loading,” never “something went wrong.”A fallback can itself await data or read a pending promise.
fallback is rendered synchronously and must not suspend. It is what you show while something loads — if it had to load too, there would be nothing to show during the wait and the boundary would have no purpose. Keep fallbacks dumb and instant: static markup, no data reads.A single <Suspense> boundary is all-or-nothing: it shows its fallback while any one child is still suspending, and reveals its children only once every child has resolved.
Reveal card-by-card review
External resources
Section titled “External resources”The canonical reference: props, nesting, and the reveal-coordination behavior this lesson builds on.
How use() reads a promise and suspends the component — the seam in the second shape you saw.
Granular boundaries, the server-promise to use() pattern, and the CLS-aware skeleton guidance, in App Router terms.
Kent C. Dodds on the throw-a-promise mechanism behind the suspend signal — the level below the contract.