Suspense, the fallback contract
React's Suspense component as the App Router's declarative loading boundary, and the skill of deciding where to draw it so each piece of UI reveals on its own.
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 about 800 milliseconds: long enough to notice. This lesson is about the user, not the code. For those 800 milliseconds, what fills the screen?
If you have written React before, your instinct is a loading flag, and in the App Router it is the wrong one. You hold an isLoading boolean in useState, flip it on, fetch inside a useEffect, and flip it off when the data lands, with if (isLoading) return <Skeleton /> somewhere in your JSX. It works, but it costs more than it looks.
The flag lives in a Client Component, because useState and useEffect only run on the client, and you have to thread it down to wherever the spinner shows. It also does not scale: the moment a page loads two independent things, 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 can get wrong, so loading state ends up scattered across the tree and easy to forget.
React’s replacement is one declarative primitive, the <Suspense> component. The syntax is the easy part. The skill that takes longer is deciding not how to write it but where to draw the boundary, and that is what the rest of this lesson works toward.
What Suspense is: a built-in component with one contract
Section titled “What Suspense is: a built-in component with one contract”Suspense is easy to miscategorize.
It is not a hook, not a next.config flag, not a data-fetching library.
It is a built-in React component you place in JSX like any other, and its contract is one sentence: while any descendant is still loading, render the fallback; once every descendant has resolved, render the children.
Three properties make that contract work.
- It is a component. You give
<Suspense>afallbackprop and somechildren. There is nouseSuspenseand no setup call. - It reacts to a signal a child throws. A component that is not ready emits a suspend signal , and
<Suspense>catches it the waytry/catchcatches a throw. So the boundary never needs to know what its children are loading or how: a child reading a database, a child reading a streamed promise, and a lazily-loaded component all look identical to it. It only knows something below said “not yet.” - It is all-or-nothing per boundary. The fallback shows while any child is still suspending, and the children show only once every child has resolved. A boundary is a unit with two visual states, and it stays in the fallback until the last holdout settles.
That last property drives where you place boundaries: everything inside one 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, a fallback and the thing it guards:
<Suspense fallback={<InvoiceSkeleton />}> <InvoiceList /></Suspense>InvoiceList is an async Server Component whose body does an 800ms await.
Until that settles, InvoiceList produces no output, so it suspends, and the nearest <Suspense> above it shows <InvoiceSkeleton /> in its place.
When the data arrives, React swaps the skeleton for the list.
You flipped no boolean; the boundary did it.
The piece you supply is the fallback .
The next section covers the two kinds of children that can sit inside a boundary and trigger it.
From loading flag to Suspense boundary
Section titled “From loading flag to Suspense boundary”Here are the two shapes side by side. They 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. It lives in three marked places: the flag, the effect that flips it, and the branch that reads it. Every new query 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 holds no loading logic, no flag, no effect, no 'use client'. Loading is expressed once, at the <Suspense> boundary placed by whoever composes the component.
There is no flag to forget to flip, because there is no flag.
The two ways a child suspends
Section titled “The two ways a child suspends”A child throws a suspend signal and the boundary catches it. But which children actually do that? There are two shapes you will write, and both throw the same signal, which is why a single <Suspense> can sit above either one without caring which it gets.
The first 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, which tells React it 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. Until this promise settles the function has returned no JSX, so the component suspends and the nearest <Suspense> ancestor shows its fallback.
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 like any synchronous component, and React swaps the fallback for this output.
The second is the one from when a Server Component handed a promise to a Client Component, now seen from the loading side. 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; 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 above the Client Component catches that signal. The fallback shows until the promise resolves, then the interactive table renders with its data.
Why reach for the second shape when the first is simpler? Because the table sorts and filters, so it has to be a Client Component. This pattern lets the interactive shell be a Client Component while its data still streams 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 returns the value the moment it resolves.
Two other things can suspend: React.lazy(), which code-splits a component so its code loads on demand, and Suspense-aware hooks from some data libraries. Neither is the default for fetching data.
A fallback must render instantly and mirror the layout
Section titled “A fallback must render instantly and mirror the layout”You write the fallback yourself. Suspense can’t generate one from the children, because it has no idea what the resolved content will look like. Two rules separate a fallback that helps from one that hurts.
The fallback renders synchronously and must not itself suspend. It is what you show while something loads, so if it also has to load, by awaiting or reading a pending promise, there is nothing to show during the wait and the boundary serves no purpose. Keep fallbacks instant: static markup, no data reads.
Prefer a skeleton that mirrors the final layout, with the same heights, the same number of rows, and the same rough shape as the content it stands in for. A spinner or a line of “Loading…” text works, but it is lower quality for a concrete reason: a spinner occupies a tiny box, so when the larger content arrives, everything below it lurches down the page. A skeleton that already reserves the content’s eventual footprint swaps in place, and nothing moves. That avoided jump is the point; layout shift on data load is a real UX defect, jarring on a surface the user hits dozens of times a day.
Recent activity
list needs this room — footer is shoved down to here
Recent activity
Drawing the boundary at the unit of UX
Section titled “Drawing the boundary at the unit of UX”You know what Suspense is, what suspends, and what a good fallback looks like. What takes judgment 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” or “which component is async,” but what the user perceives as one thing arriving. 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 slow read holds the fast one back: the user waits the full 800ms to see the 10ms profile. A boundary is all-or-nothing, so the profile cannot appear until the feed resolves too.
function DashboardPage() { return ( <> <Suspense fallback={<ProfileSkeleton />}> <UserProfile /> </Suspense> <Suspense fallback={<FeedSkeleton />}> <ActivityFeed /> </Suspense> </> );}Each read reveals on its own, so the profile paints almost immediately. Two boundaries means two independent units: the profile shows at 10ms beside the feed’s skeleton, and the feed fills in when it’s ready.
The diagram makes 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.
The rule: 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: why two parallel boundaries each reveal separately, the mechanism that lets the server send the fast one first, is the next lesson, on streaming. Here you are learning only the placement decision.
Now walk the decision yourself. The drill 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. Nesting buys you a content-first reveal, shell then partial then full, with no coordinating state.
The all-or-nothing rule drives it. An outer boundary’s fallback covers everything beneath it until its directly-awaited content is ready. Once that content renders, an inner boundary takes over for its own slower subtree, showing its fallback while everything around it is already on screen. Each boundary minds only its own children.
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, the cascade is one boundary inside another with some synchronous content between them:
<Suspense fallback={<PageSkeleton />}> <DashboardHeader /> <Suspense fallback={<FeedSkeleton />}> <ActivityFeed /> </Suspense></Suspense>The outer fallback shows until DashboardHeader is ready. Then the header paints and the inner boundary takes over, showing <FeedSkeleton /> while ActivityFeed finishes its slow read: three visual states, zero state variables.
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, 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”Here is the trap, 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, treats it as the same instance, reuses the already-resolved subtree, and skips the fallback entirely. The user keeps seeing inv_001’s data, stale and wrong, for the full new load, with no loading indication. It looks like nothing happened, then the content silently changes underneath them.
The fix tells React 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 forces a fresh mount, which re-suspends, which brings the fallback back. This is the clean way to show a loading state when the route parameter changes: no isLoading flag, no effect, just an identity hint.
The deliberate opposite exists for when you don’t want the fallback on a change, like search-as-you-type, where flashing a skeleton on every keystroke is jarring. Wrapping the update in startTransition keeps the old content visible and gives you an isPending flag instead. It has its own React lesson; for now, just know key and transitions pull in opposite directions on purpose.
What Suspense does not do
Section titled “What Suspense does not do”Each item below is a real bug someone ships. Suspense has one job; these are not it.
- It does not catch errors. If a component below the boundary throws, that error sails past Suspense and crashes the tree. Catching it is the job of an Error Boundary, which the App Router exposes through
error.tsx; we cover that, and the “resource not found” case, two lessons from now. Suspense catches a suspend signal, not an error. - It does not retry, and the
fallbackis not an error state. The fallback means “still loading,” nothing more. Suspense will not re-attempt a failed load for you. - It does not deduplicate fetches. Two children reading the same data fire two requests; making the second one cheap is request memoization with
cache(), covered when we reach caching. - It does not auto-skeleton. You write the
fallback. Suspense has no view into the children’s shape and cannot generate a placeholder from them.
Practice: place the boundary
Section titled “Practice: place the boundary”You read the unit-of-UX decision; now make it. Below, two simulated-async widgets share a single boundary. Give the slow one its own.
This runs entirely in the browser. Suspending is faked with a setTimeout-backed promise read by use(), standing in for the async Server Component you’d write in real App Router code. Read it as “this widget takes 50ms or 1500ms to be ready.”
Right now App wraps both widgets in one shared <Suspense>. That boundary is all-or-nothing: its fallback covers everything until the 1500ms slow read finishes, holding the fast widget back. Split it so the slow widget gets its own boundary and 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> and skeleton, so the two reads are independent. The profile’s boundary resolves at about 50ms and paints while the feed still shows SlowSkeleton; the feed fills in at about 1500ms. Only App changes.
export function App() { return ( <> <Suspense fallback={<FastSkeleton />}> <UserProfile /> </Suspense> <Suspense fallback={<SlowSkeleton />}> <ActivityFeed /> </Suspense> </> );}SharedSkeleton is now unused: it was exactly the combined fallback 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.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 what Suspense does, and more often does not, do. 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.